mirror of
https://github.com/storytold/craft-ai-integrations.git
synced 2026-11-02 18:06:49 +00:00
Migrate shared AI widgets to egui 0.37 (#105)
* refactor(ai-egui): migrate shared widgets to egui 0.37 * egui037: qualify shared AI widgets and downstream dependency pins * docs(ai-egui): record the committed migration qualification * fix(ci): provide Vulkan snapshots and allow pinned fork sources * test: make cancellation-before-load coverage deterministic
This commit is contained in:
committed by
GitHub
parent
e2c23c1c2b
commit
0e55340771
@@ -48,6 +48,15 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install software Vulkan renderer for kittest snapshots
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y mesa-vulkan-drivers libvulkan1
|
||||
# kittest prefers a CPU adapter. Mesa supplies lavapipe for headless Vulkan;
|
||||
# selecting Vulkan avoids relying on a display-backed GL adapter.
|
||||
# Windows and macOS keep their existing graphics backends.
|
||||
echo 'WGPU_BACKEND=vulkan' >> "$GITHUB_ENV"
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
@@ -3,3 +3,6 @@ target
|
||||
__pycache__/
|
||||
*.pyc
|
||||
tools/mutation/verification/
|
||||
**/tests/snapshots/**/*.diff.png
|
||||
**/tests/snapshots/**/*.new.png
|
||||
**/tests/snapshots/**/*.old.png
|
||||
|
||||
Generated
+230
-142
@@ -4,21 +4,21 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "accesskit"
|
||||
version = "0.24.1"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
|
||||
checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932"
|
||||
dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.35.0"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
|
||||
checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -294,6 +294,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.52.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -1331,7 +1332,7 @@ dependencies = [
|
||||
"burn-dispatch",
|
||||
"burn-einsum",
|
||||
"burn-std",
|
||||
"colored",
|
||||
"colored 3.1.1",
|
||||
"derive-new",
|
||||
"num-traits",
|
||||
"serde",
|
||||
@@ -1453,42 +1454,18 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"polling",
|
||||
"rustix 1.1.5",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop-wayland-source"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
|
||||
dependencies = [
|
||||
"calloop 0.13.0",
|
||||
"calloop",
|
||||
"rustix 0.38.44",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop-wayland-source"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
|
||||
dependencies = [
|
||||
"calloop 0.14.5",
|
||||
"rustix 1.1.5",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "candle-core"
|
||||
version = "0.9.2"
|
||||
@@ -1799,6 +1776,16 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.1.1"
|
||||
@@ -3183,6 +3170,19 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dify"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"colored 2.2.0",
|
||||
"getopts",
|
||||
"image",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -3324,9 +3324,8 @@ checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9"
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"emath",
|
||||
@@ -3334,9 +3333,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "eframe"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -3356,21 +3354,23 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"pollster",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"send_wrapper",
|
||||
"static_assertions",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"wgpu",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"ahash",
|
||||
@@ -3388,9 +3388,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "egui-wgpu"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -3408,29 +3407,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "egui-winit"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-ui-kit 0.3.2",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
@@ -3442,15 +3441,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "egui_kittest"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"dify",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"kittest",
|
||||
"log",
|
||||
"open",
|
||||
"pollster",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3461,9 +3467,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "emath"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
@@ -3606,9 +3611,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "epaint"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -3624,16 +3628,17 @@ dependencies = [
|
||||
"self_cell",
|
||||
"skrifa",
|
||||
"smallvec",
|
||||
"unicode-bidi",
|
||||
"unicode-general-category",
|
||||
"unicode-properties",
|
||||
"unicode-segmentation",
|
||||
"vello_cpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "epaint_default_fonts"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
@@ -3795,6 +3800,12 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
@@ -4261,6 +4272,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -4341,9 +4361,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "glifo"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
|
||||
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash 0.2.0",
|
||||
@@ -4965,6 +4985,25 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -5129,9 +5168,9 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kittest"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
|
||||
checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer",
|
||||
@@ -5921,6 +5960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -6288,6 +6328,16 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
@@ -6329,6 +6379,16 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owo-colors"
|
||||
version = "3.5.0"
|
||||
@@ -6443,6 +6503,17 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phc"
|
||||
version = "0.6.1"
|
||||
@@ -6600,6 +6671,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pollster"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.9.1"
|
||||
@@ -7714,6 +7791,12 @@ version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "send_wrapper"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
|
||||
|
||||
[[package]]
|
||||
name = "seq-macro"
|
||||
version = "0.3.6"
|
||||
@@ -7984,8 +8067,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"calloop 0.13.0",
|
||||
"calloop-wayland-source 0.3.0",
|
||||
"calloop",
|
||||
"calloop-wayland-source",
|
||||
"cursor-icon",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -8002,44 +8085,6 @@ dependencies = [
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithay-client-toolkit"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"calloop 0.14.5",
|
||||
"calloop-wayland-source 0.4.1",
|
||||
"cursor-icon",
|
||||
"libc",
|
||||
"log",
|
||||
"memmap2",
|
||||
"rustix 1.1.5",
|
||||
"thiserror 2.0.21",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-csd-frame",
|
||||
"wayland-cursor",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-experimental",
|
||||
"wayland-protocols-misc",
|
||||
"wayland-protocols-wlr",
|
||||
"wayland-scanner",
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithay-clipboard"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"smithay-client-toolkit 0.20.0",
|
||||
"wayland-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.2.2"
|
||||
@@ -8529,7 +8574,6 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -8598,6 +8642,17 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom 8.0.0",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "triple_arena"
|
||||
version = "0.15.1"
|
||||
@@ -8807,7 +8862,7 @@ version = "2.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
|
||||
dependencies = [
|
||||
"rand 0.9.5",
|
||||
"rand 0.10.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8901,6 +8956,12 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-general-category"
|
||||
version = "1.1.0"
|
||||
@@ -8913,6 +8974,12 @@ version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
@@ -9108,9 +9175,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
|
||||
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd",
|
||||
@@ -9123,9 +9190,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vello_cpu"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
|
||||
checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"glifo",
|
||||
@@ -9286,32 +9353,6 @@ dependencies = [
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-experimental"
|
||||
version = "20250721.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-misc"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-plasma"
|
||||
version = "0.3.12"
|
||||
@@ -9472,6 +9513,7 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"wgpu-core-deps-apple",
|
||||
"wgpu-core-deps-emscripten",
|
||||
"wgpu-core-deps-wasm",
|
||||
"wgpu-core-deps-windows-linux-android",
|
||||
"wgpu-hal",
|
||||
"wgpu-naga-bridge",
|
||||
@@ -9496,6 +9538,15 @@ dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-wasm"
|
||||
version = "30.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae"
|
||||
dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-windows-linux-android"
|
||||
version = "30.0.1"
|
||||
@@ -9858,8 +9909,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
[[package]]
|
||||
name = "winit"
|
||||
version = "0.30.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
|
||||
source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"android-activity",
|
||||
@@ -9867,7 +9917,7 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.5.1",
|
||||
"bytemuck",
|
||||
"calloop 0.13.0",
|
||||
"calloop",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation 0.9.4",
|
||||
@@ -9888,7 +9938,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smithay-client-toolkit",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
@@ -9921,6 +9971,24 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 1.1.5",
|
||||
"thiserror 2.0.21",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wrapcenum-derive"
|
||||
version = "0.4.1"
|
||||
@@ -10400,3 +10468,23 @@ dependencies = [
|
||||
"syn 3.0.7",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_system_font_provider"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
+18
@@ -23,3 +23,21 @@ unsafe_code = "forbid"
|
||||
[workspace.lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
[patch.crates-io]
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -35,6 +35,12 @@ Layout and rules mirror craft-libs: edition 2024, `MIT OR Apache-2.0`, no `unsaf
|
||||
| `craft-ai-comfy-env` | Finds an existing ComfyUI Desktop install, inventories its model files and node packs, downloads missing model files into it. |
|
||||
| `craft-ai-egui` | Theme-neutral egui widgets: status, model picker, consent prompt, job progress, connection panel, history. Needs Rust 1.95. |
|
||||
|
||||
`craft-ai-egui` targets egui 0.37 and resolves it through the reviewed storytold family at
|
||||
`6d518fc799681571963842f95b55a6b68431b15a`, with winit at
|
||||
`a517b3846ee8457d84ee4975110bfda6797c44dc`. An adopting app repeats the complete `[patch.crates-io]`
|
||||
recipe at its own root; `consumer-smoke/` is the small compile/check root for that recipe and the
|
||||
single `craft-ai-core` identity. Bundled fonts stay off in the library and are a host choice.
|
||||
|
||||
Building on this layer (use a tool in your app, declare a new tool, or bring a service or model): start at [docs/adopting](docs/adopting/README.md). The short version is [docs/ADOPT-A-TOOL.md](docs/ADOPT-A-TOOL.md).
|
||||
|
||||
Which crate owns what, and what stays in each app: [docs/LAYOUT.md](docs/LAYOUT.md). The shared-tool
|
||||
@@ -67,8 +73,15 @@ Read [docs/STATUS.md](docs/STATUS.md) before relying on a provider or tool. In s
|
||||
- **Tested against scripted servers on localhost:** every hosted adapter, the media copier, the generic
|
||||
ComfyUI adapter, the declarative adapter and the client flow.
|
||||
- **Run for real on one machine:** the SAM 3 point and session clients through a headless ComfyUI; SAM 2.1 on rten with the published ONNX files; and an imported selection graph on ComfyUI 0.22.3. All on Windows, with one sample picture.
|
||||
- **Migrated shared widgets, headless:** `craft-ai-egui` passed 108 tests, strict all-target clippy
|
||||
and `cargo fmt --check` on Linux, five committed kittest snapshots, and the locked consumer-smoke
|
||||
check with one draw test and one `craft-ai-core` identity. The rendererless smoke does not use
|
||||
winit.
|
||||
- **Not verified:** any real hosted service, TLS behind an intercepting proxy, macOS and Linux
|
||||
keychains, macOS builds of the test suite, and any app running a tool through the shared binding.
|
||||
Native widget behavior on Linux X11/Wayland and Windows (focus, keyboard, clipboard, IME and
|
||||
rendering), physical-GPU qualification, performance, and app adoption of the widgets also remain
|
||||
unverified.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
Generated
+2974
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
|
||||
[package]
|
||||
name = "craft-ai-consumer-smoke"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
craft-ai-egui = { path = "../crates/ai-egui", package = "craft-ai-egui" }
|
||||
craft-ai-core = { path = "../crates/ai-core", package = "craft-ai-core" }
|
||||
# A host draws the widgets in its own Ui. Tests/examples use bundled fonts; the library does not.
|
||||
egui = { version = "0.37", default-features = false, features = ["default_fonts"] }
|
||||
|
||||
# A consumer root repeats the complete recipe; the repository root's patch is not inherited.
|
||||
# Keep every family package on the reviewed storytold egui commit and winit on its paired commit.
|
||||
[patch.crates-io]
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
@@ -0,0 +1,33 @@
|
||||
# Consumer smoke root
|
||||
|
||||
This separate `[workspace]` checks the dependency recipe an app copies when it adopts
|
||||
`craft-ai-egui`. It is **not** a repository workspace member and it changes no root manifest or
|
||||
root lockfile.
|
||||
|
||||
- Path dependencies: `../crates/ai-egui` and the same `../crates/ai-core` package.
|
||||
- One `craft-ai-core` identity: `src/lib.rs` names `ConnectionDraft`, `ConnectionId` and
|
||||
`DraftError` through `craft_ai_core` and takes them from the public widget state. A second core
|
||||
copy makes the crate fail to compile.
|
||||
- Repeated patches: the complete `[patch.crates-io]` recipe is at the reviewed egui commit
|
||||
`6d518fc799681571963842f95b55a6b68431b15a` and winit `a517b3846ee8457d84ee4975110bfda6797c44dc`.
|
||||
- Fonts: the smoke root enables `egui/default_fonts`. The library dependency does not.
|
||||
- Synthetic data only: the unit test draws the form, progress and empty history with no provider,
|
||||
download, shared store or OS keychain call.
|
||||
|
||||
The separate `Cargo.lock` pins this smoke root. Run the locked checks and inspect the duplicate
|
||||
report from
|
||||
the `consumer-smoke/` directory:
|
||||
|
||||
```sh
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
cargo check --locked -p craft-ai-consumer-smoke
|
||||
cargo test --locked -p craft-ai-consumer-smoke
|
||||
cargo tree --locked -d
|
||||
cargo tree --locked -i craft-ai-core
|
||||
```
|
||||
|
||||
Expected graph: exactly one `craft-ai-core` identity and one egui family at the reviewed commit.
|
||||
This headless smoke does not select winit; the paired winit patch is included for native adopters.
|
||||
The smoke explicitly clears its unrendered texture delta. A native host must apply texture updates
|
||||
to its renderer. Do not copy this root's lockfile into an app; each consumer resolves and reviews
|
||||
its own.
|
||||
@@ -0,0 +1,72 @@
|
||||
//! A separate consumer root for the public `craft-ai-egui` API.
|
||||
//!
|
||||
//! This crate is not a member of the repository workspace. Its own `[patch.crates-io]` recipe is
|
||||
//! the one an adopting app must repeat. It path-depends on this repository's `crates/ai-egui` and
|
||||
//! `crates/ai-core`, then names the widget-facing core types through `craft_ai_core`; a second
|
||||
//! `craft-ai-core` package (for example from `craft-libs`) would make that assertion fail to
|
||||
//! compile. Draw calls use only synthetic data and no host service.
|
||||
|
||||
use craft_ai_core::{ConnectionDraft, ConnectionId, DraftError, HistoryLog, JobState};
|
||||
use craft_ai_egui::{
|
||||
ConnectionFormState, HistoryViewState, PanelEvent, connection_form, history_view, job_progress,
|
||||
};
|
||||
|
||||
/// Draws the public widgets in a host-owned `Ui`.
|
||||
///
|
||||
/// An adopting app supplies its own fonts and store. This function is compile/runtime smoke only;
|
||||
/// it starts no provider, download or keychain call.
|
||||
pub fn draw_public_widgets(
|
||||
ui: &mut egui::Ui,
|
||||
form: &mut ConnectionFormState,
|
||||
history: &mut HistoryViewState,
|
||||
log: &HistoryLog,
|
||||
now_ms: u64,
|
||||
) {
|
||||
let _ = connection_form(ui, form, false);
|
||||
let _ = job_progress(ui, &JobState::Running { percent: Some(25) }, None);
|
||||
let _ = history_view(ui, history, log, now_ms);
|
||||
}
|
||||
|
||||
/// The widget-facing values are this workspace's `craft-ai-core` types, not a second identity.
|
||||
pub fn widget_values_are_the_local_core(form: &ConnectionFormState) {
|
||||
let _: &ConnectionDraft = &form.draft;
|
||||
let _: &Option<ConnectionId> = &form.draft.editing;
|
||||
if let Some(error) = form.error.as_ref() {
|
||||
let _: &DraftError = error;
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel's saved id is the same core `ConnectionId` an app binds with.
|
||||
pub fn saved_id(event: PanelEvent) -> Option<ConnectionId> {
|
||||
match event {
|
||||
PanelEvent::Saved(id) => Some(id),
|
||||
PanelEvent::Closed => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn public_widgets_draw_without_a_provider_or_store() {
|
||||
let mut form = ConnectionFormState::new();
|
||||
let mut history = HistoryViewState::default();
|
||||
let log = HistoryLog::default();
|
||||
let ctx = egui::Context::default();
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(320.0, 480.0),
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut output = ctx.run_ui(input, |ui| {
|
||||
draw_public_widgets(ui, &mut form, &mut history, &log, 1_760_000_000_000);
|
||||
});
|
||||
// This headless API smoke has no renderer; deliberately discard its atlas upload.
|
||||
// A native host must apply the texture delta before painting instead.
|
||||
output.textures_delta.clear();
|
||||
widget_values_are_the_local_core(&form);
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,15 @@ craft-ai-core.workspace = true
|
||||
craft-ai-client = { path = "../ai-client" }
|
||||
craft-ai-features = { path = "../ai-features" }
|
||||
craft-ai-native = { path = "../ai-native", package = "craft-ai-native" }
|
||||
egui = "0.36"
|
||||
# The workspace root's [patch.crates-io] resolves this to the reviewed storytold 0.37 family.
|
||||
# Bundled fonts stay off in the library; tests/examples opt in and hosts own their font policy.
|
||||
egui = { version = "0.37", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = "0.36"
|
||||
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
|
||||
# Tests need bundled fonts for the missing-glyph fixture; the library dependency stays font-free.
|
||||
egui = { version = "0.37", default-features = false, features = ["default_fonts"] }
|
||||
egui_kittest = { version = "0.37", features = ["wgpu", "snapshot"] }
|
||||
eframe = { version = "0.37", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
|
||||
craft-ai-features = { path = "../ai-features", package = "craft-ai-features" }
|
||||
tempfile = "3"
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! Nothing here deletes or writes the real log. Clear only empties what the window shows, and
|
||||
//! Export writes `history-export.csv` into the current directory.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use craft_ai_core::history::{EndRecord, HISTORY_SCHEMA, StartRecord, merge};
|
||||
@@ -165,6 +166,8 @@ struct Demo {
|
||||
log: HistoryLog,
|
||||
view: HistoryViewState,
|
||||
note: String,
|
||||
/// Actions reported by the view; applied once in `logic`, not in the paint callback.
|
||||
pending: VecDeque<HistoryAction>,
|
||||
}
|
||||
|
||||
impl Demo {
|
||||
@@ -177,9 +180,42 @@ impl Demo {
|
||||
_ => HistoryLog::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues an action once. A host that draws twice in one frame reports the same press twice;
|
||||
/// the second copy must not write the export again.
|
||||
fn queue(&mut self, action: HistoryAction) {
|
||||
if !self.pending.contains(&action) {
|
||||
self.pending.push_back(action);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&mut self, action: HistoryAction) {
|
||||
match action {
|
||||
HistoryAction::Refresh => {
|
||||
self.log = Self::load(self.real);
|
||||
self.note = "Refreshed.".into();
|
||||
}
|
||||
HistoryAction::Clear => {
|
||||
self.log = HistoryLog::default();
|
||||
self.note = "Cleared in this window only. The file on disk was not touched.".into();
|
||||
}
|
||||
HistoryAction::Export(csv) => {
|
||||
self.note = match std::fs::write("history-export.csv", csv) {
|
||||
Ok(()) => "Wrote history-export.csv in the current folder.".into(),
|
||||
Err(e) => format!("Could not write the file: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Demo {
|
||||
fn logic(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
while let Some(action) = self.pending.pop_front() {
|
||||
self.apply(action);
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
ui.heading(if self.real {
|
||||
@@ -188,23 +224,9 @@ impl eframe::App for Demo {
|
||||
"AI history (sample data)"
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
match history_view(ui, &mut self.view, &self.log, now_ms()) {
|
||||
Some(HistoryAction::Refresh) => {
|
||||
self.log = Self::load(self.real);
|
||||
self.note = "Refreshed.".into();
|
||||
}
|
||||
Some(HistoryAction::Clear) => {
|
||||
self.log = HistoryLog::default();
|
||||
self.note =
|
||||
"Cleared in this window only. The file on disk was not touched.".into();
|
||||
}
|
||||
Some(HistoryAction::Export(csv)) => {
|
||||
self.note = match std::fs::write("history-export.csv", csv) {
|
||||
Ok(()) => "Wrote history-export.csv in the current folder.".into(),
|
||||
Err(e) => format!("Could not write the file: {e}"),
|
||||
};
|
||||
}
|
||||
None => {}
|
||||
if let Some(action) = history_view(ui, &mut self.view, &self.log, now_ms()) {
|
||||
self.queue(action);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
if !self.note.is_empty() {
|
||||
ui.add_space(6.0);
|
||||
@@ -229,6 +251,7 @@ fn main() -> eframe::Result {
|
||||
log: Demo::load(real),
|
||||
view: HistoryViewState::default().with_offset(OFFSET_MINUTES),
|
||||
note: String::new(),
|
||||
pending: VecDeque::new(),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! (through `ToolDesk::choose`), so the "in use" mark moves. Set up links only say what they would
|
||||
//! open.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_ai_client::{AiClient, PickerView, SetupLink, ToolDesk};
|
||||
@@ -134,6 +135,15 @@ fn build(sam3_installed: bool) -> Sample {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the window asked for. Every one of these is applied in `logic`, once, not in `ui`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum DemoAction {
|
||||
Rebuild,
|
||||
Refresh,
|
||||
Picker(PickerAction),
|
||||
Popup(PopupAction),
|
||||
}
|
||||
|
||||
struct Demo {
|
||||
sample: Sample,
|
||||
sam3_installed: bool,
|
||||
@@ -142,6 +152,7 @@ struct Demo {
|
||||
dismissed: bool,
|
||||
view: PickerView,
|
||||
note: String,
|
||||
pending: VecDeque<DemoAction>,
|
||||
}
|
||||
|
||||
impl Demo {
|
||||
@@ -167,6 +178,60 @@ impl Demo {
|
||||
self.view = view;
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues an action once. A repeated UI pass for the same press must not choose twice or
|
||||
/// download twice.
|
||||
fn queue(&mut self, action: DemoAction) {
|
||||
if !self.pending.contains(&action) {
|
||||
self.pending.push_back(action);
|
||||
}
|
||||
}
|
||||
|
||||
fn choose(&mut self, connection: ConnectionId, model: ModelId, every_app: bool) {
|
||||
let desk = ToolDesk::new(
|
||||
self.client(if every_app { "photocraft" } else { "launcher" }),
|
||||
self.sample.shared.clone(),
|
||||
None,
|
||||
);
|
||||
let suffix = if every_app { " for every app" } else { "" };
|
||||
self.note = match desk.choose(&self.role(), &connection, &model) {
|
||||
Ok(()) => format!("Chose {model} on {connection}{suffix}."),
|
||||
Err(e) => format!("Refused: {e}"),
|
||||
};
|
||||
self.refresh();
|
||||
}
|
||||
|
||||
fn apply(&mut self, action: DemoAction) {
|
||||
match action {
|
||||
DemoAction::Rebuild => {
|
||||
self.sample = build(self.sam3_installed);
|
||||
self.refresh();
|
||||
}
|
||||
DemoAction::Refresh => self.refresh(),
|
||||
DemoAction::Picker(PickerAction::Choose { connection, model }) => {
|
||||
self.choose(connection, model, false);
|
||||
}
|
||||
DemoAction::Picker(PickerAction::SetUp(SetupLink::New { option })) => {
|
||||
self.note = format!("Would open set up for {option}.");
|
||||
}
|
||||
DemoAction::Picker(PickerAction::SetUp(SetupLink::Fix { connection })) => {
|
||||
self.note = format!("Would open the launcher to fix {connection}.");
|
||||
}
|
||||
DemoAction::Popup(PopupAction::Choose { connection, model }) => {
|
||||
self.choose(connection, model, true);
|
||||
}
|
||||
DemoAction::Popup(PopupAction::SetUp(SetupLink::New { option })) => {
|
||||
self.note = format!("Would open set up for {option}.");
|
||||
}
|
||||
DemoAction::Popup(PopupAction::SetUp(SetupLink::Fix { connection })) => {
|
||||
self.note = format!("Would open the launcher to fix {connection}.");
|
||||
}
|
||||
DemoAction::Popup(PopupAction::NotNow) => self.dismissed = true,
|
||||
DemoAction::Popup(PopupAction::InstallComfyUi(_)) => {
|
||||
self.note = "This sample does not install software.".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Demo {
|
||||
@@ -193,31 +258,20 @@ impl Demo {
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.show(ui.ctx(), |ui| action = missing_model_popup(ui, &popup));
|
||||
match action {
|
||||
Some(PopupAction::Choose { connection, model }) => {
|
||||
// Bound for every app, not just this one.
|
||||
self.note = match desk.choose(&self.role(), &connection, &model) {
|
||||
Ok(()) => format!("Chose {model} on {connection} for every app."),
|
||||
Err(e) => format!("Refused: {e}"),
|
||||
};
|
||||
self.refresh();
|
||||
}
|
||||
Some(PopupAction::SetUp(SetupLink::New { option })) => {
|
||||
self.note = format!("Would open set up for {option}.");
|
||||
}
|
||||
Some(PopupAction::SetUp(SetupLink::Fix { connection })) => {
|
||||
self.note = format!("Would open the launcher to fix {connection}.");
|
||||
}
|
||||
Some(PopupAction::NotNow) => self.dismissed = true,
|
||||
Some(PopupAction::InstallComfyUi(_)) => {
|
||||
self.note = "This sample does not install software.".into();
|
||||
}
|
||||
None => {}
|
||||
if let Some(action) = action {
|
||||
self.queue(DemoAction::Popup(action));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Demo {
|
||||
fn logic(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
while let Some(action) = self.pending.pop_front() {
|
||||
self.apply(action);
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
ui.label(
|
||||
@@ -237,10 +291,11 @@ impl eframe::App for Demo {
|
||||
}
|
||||
});
|
||||
if before.1 != self.sam3_installed {
|
||||
self.sample = build(self.sam3_installed);
|
||||
}
|
||||
if before != (self.tool, self.sam3_installed) {
|
||||
self.refresh();
|
||||
self.queue(DemoAction::Rebuild);
|
||||
ui.ctx().request_repaint();
|
||||
} else if before.0 != self.tool {
|
||||
self.queue(DemoAction::Refresh);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
ui.separator();
|
||||
if self.as_popup {
|
||||
@@ -251,23 +306,9 @@ impl eframe::App for Demo {
|
||||
}
|
||||
return;
|
||||
}
|
||||
match model_picker(ui, &self.view) {
|
||||
Some(PickerAction::Choose { connection, model }) => {
|
||||
let desk =
|
||||
ToolDesk::new(self.client("launcher"), self.sample.shared.clone(), None);
|
||||
self.note = match desk.choose(&self.role(), &connection, &model) {
|
||||
Ok(()) => format!("Chose {model} on {connection}."),
|
||||
Err(e) => format!("Refused: {e}"),
|
||||
};
|
||||
self.refresh();
|
||||
}
|
||||
Some(PickerAction::SetUp(SetupLink::New { option })) => {
|
||||
self.note = format!("Would open set up for {option}.");
|
||||
}
|
||||
Some(PickerAction::SetUp(SetupLink::Fix { connection })) => {
|
||||
self.note = format!("Would open the launcher to fix {connection}.");
|
||||
}
|
||||
None => {}
|
||||
if let Some(action) = model_picker(ui, &self.view) {
|
||||
self.queue(DemoAction::Picker(action));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
if !self.note.is_empty() {
|
||||
ui.separator();
|
||||
@@ -300,6 +341,7 @@ fn main() -> eframe::Result {
|
||||
groups: Vec::new(),
|
||||
},
|
||||
note: String::new(),
|
||||
pending: VecDeque::new(),
|
||||
};
|
||||
demo.refresh();
|
||||
Ok(Box::new(demo))
|
||||
|
||||
@@ -113,7 +113,7 @@ fn draw_consent(
|
||||
ui.radio_value(selected, duration, consent_term_label(duration));
|
||||
}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui.button(ALLOW).clicked() {
|
||||
choice = Some(ConsentChoice::Allow);
|
||||
}
|
||||
|
||||
+22
-11
@@ -6,7 +6,7 @@
|
||||
//! own look can reuse them.
|
||||
|
||||
use craft_ai_core::{Connection, ConnectionDraft, DraftError, Field, Locality, PRESETS};
|
||||
use egui::{ComboBox, RichText, TextEdit, Ui};
|
||||
use egui::{ComboBox, Response, RichText, TextEdit, Ui};
|
||||
|
||||
/// The form's sentences.
|
||||
pub mod words {
|
||||
@@ -141,8 +141,8 @@ pub fn connection_form(
|
||||
});
|
||||
if adding {
|
||||
let mut picked = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(words::START_FROM);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let label = ui.label(words::START_FROM);
|
||||
let current = PRESETS
|
||||
.iter()
|
||||
.find(|p| state.preset == Some(p.key))
|
||||
@@ -158,7 +158,9 @@ pub fn connection_form(
|
||||
picked = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
});
|
||||
if let Some(note) = PRESETS
|
||||
.iter()
|
||||
@@ -173,24 +175,31 @@ pub fn connection_form(
|
||||
}
|
||||
|
||||
let bad = |field: Field| state.error.as_ref().is_some_and(|e| e.field == field);
|
||||
let heading = |ui: &mut Ui, text: &str, field: Field| {
|
||||
let heading = |ui: &mut Ui, text: &str, field: Field| -> Response {
|
||||
let t = RichText::new(text);
|
||||
ui.label(if bad(field) {
|
||||
t.color(ui.visuals().error_fg_color)
|
||||
} else {
|
||||
t
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
heading(ui, words::NAME, Field::Label);
|
||||
ui.add(TextEdit::singleline(&mut state.draft.label).desired_width(f32::INFINITY));
|
||||
heading(ui, words::ADDRESS, Field::Address);
|
||||
let name_heading = heading(ui, words::NAME, Field::Label);
|
||||
ui.add(
|
||||
TextEdit::singleline(&mut state.draft.label)
|
||||
.id_salt("craft-ai-connection-name")
|
||||
.desired_width(f32::INFINITY),
|
||||
)
|
||||
.labelled_by(name_heading.id);
|
||||
let address_heading = heading(ui, words::ADDRESS, Field::Address);
|
||||
let moved = ui
|
||||
.add(
|
||||
TextEdit::singleline(&mut state.draft.endpoint)
|
||||
.id_salt("craft-ai-connection-address")
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text("https://\u{2026}"),
|
||||
)
|
||||
.labelled_by(address_heading.id)
|
||||
.changed();
|
||||
let mut edited = moved;
|
||||
if let Some(l) = state.draft.locality() {
|
||||
@@ -204,7 +213,7 @@ pub fn connection_form(
|
||||
.checkbox(&mut state.draft.uses_key, words::USES_KEY)
|
||||
.changed();
|
||||
if state.draft.uses_key {
|
||||
heading(ui, words::KEY, Field::Key);
|
||||
let key_heading = heading(ui, words::KEY, Field::Key);
|
||||
let hint = if adding {
|
||||
words::KEY_HINT_NEW
|
||||
} else {
|
||||
@@ -213,10 +222,12 @@ pub fn connection_form(
|
||||
edited |= ui
|
||||
.add(
|
||||
TextEdit::singleline(&mut state.draft.key)
|
||||
.id_salt("craft-ai-connection-key")
|
||||
.password(true)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text(hint),
|
||||
)
|
||||
.labelled_by(key_heading.id)
|
||||
.changed();
|
||||
}
|
||||
// An earlier test, and the models it found, no longer describe what is typed.
|
||||
@@ -243,7 +254,7 @@ pub fn connection_form(
|
||||
};
|
||||
ui.label(RichText::new(line).small().color(colour));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui.button(words::SAVE).clicked() {
|
||||
event = Some(FormEvent::Save);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ pub const EMPTY: &str = "No AI jobs have been run yet.";
|
||||
pub const NOTHING_MATCHES: &str = "No jobs match these filters.";
|
||||
|
||||
const MS_PER_DAY: u64 = 86_400_000;
|
||||
const ROW_HEIGHT: f32 = 20.0;
|
||||
const LIST_HEIGHT: f32 = 320.0;
|
||||
|
||||
/// How far back to look.
|
||||
@@ -71,7 +70,7 @@ impl Range {
|
||||
}
|
||||
|
||||
/// Which table to show.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)]
|
||||
pub enum Tab {
|
||||
#[default]
|
||||
Jobs,
|
||||
@@ -127,13 +126,20 @@ impl HistoryViewState {
|
||||
}
|
||||
}
|
||||
|
||||
fn pick(ui: &mut Ui, id: &str, all: &str, options: &[String], current: &mut Option<String>) {
|
||||
fn pick(
|
||||
ui: &mut Ui,
|
||||
id: &str,
|
||||
name: &str,
|
||||
all: &str,
|
||||
options: &[String],
|
||||
current: &mut Option<String>,
|
||||
) {
|
||||
// A filter left on a value that has since left the log would hide everything; fall back.
|
||||
if current.as_ref().is_some_and(|c| !options.contains(c)) {
|
||||
*current = None;
|
||||
}
|
||||
let shown = current.clone().unwrap_or_else(|| all.to_owned());
|
||||
ComboBox::from_id_salt(id)
|
||||
let combo = ComboBox::from_id_salt(id)
|
||||
.selected_text(shown)
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(current, None, all);
|
||||
@@ -141,6 +147,7 @@ fn pick(ui: &mut Ui, id: &str, all: &str, options: &[String], current: &mut Opti
|
||||
ui.selectable_value(current, Some(option.clone()), option);
|
||||
}
|
||||
});
|
||||
combo.response.accessible_name(name);
|
||||
}
|
||||
|
||||
/// Draws the history. `now_ms` is the current Unix time in milliseconds, passed in so the view
|
||||
@@ -155,10 +162,18 @@ pub fn history_view(
|
||||
let app_options = apps(&log.entries);
|
||||
let model_options = models(&log.entries);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
pick(ui, "history-app", "All apps", &app_options, &mut state.app);
|
||||
pick(
|
||||
ui,
|
||||
"history-app",
|
||||
"Filter by app",
|
||||
"All apps",
|
||||
&app_options,
|
||||
&mut state.app,
|
||||
);
|
||||
pick(
|
||||
ui,
|
||||
"history-model",
|
||||
"Filter by model",
|
||||
"All models",
|
||||
&model_options,
|
||||
&mut state.model,
|
||||
@@ -169,7 +184,9 @@ pub fn history_view(
|
||||
for range in Range::ALL {
|
||||
ui.selectable_value(&mut state.range, range, range.label());
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.accessible_name("Date range");
|
||||
if ui.button(REFRESH).clicked() {
|
||||
action = Some(HistoryAction::Refresh);
|
||||
}
|
||||
@@ -178,7 +195,7 @@ pub fn history_view(
|
||||
let filter = state.filter(now_ms);
|
||||
let shown = filter.apply(&log.entries);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (tab, label) in [
|
||||
(Tab::Jobs, TAB_JOBS),
|
||||
(Tab::Days, TAB_DAYS),
|
||||
@@ -199,10 +216,13 @@ pub fn history_view(
|
||||
ui.add_space(4.0);
|
||||
match state.tab {
|
||||
Tab::Jobs => {
|
||||
// `show_rows` adds `item_spacing.y` itself, so this is the text height only.
|
||||
let row_height = ui.text_style_height(&egui::TextStyle::Body);
|
||||
ScrollArea::vertical()
|
||||
.id_salt(("history-list", state.tab))
|
||||
.max_height(LIST_HEIGHT)
|
||||
.auto_shrink([false, true])
|
||||
.show_rows(ui, ROW_HEIGHT, shown.len(), |ui, range| {
|
||||
.show_rows(ui, row_height, shown.len(), |ui, range| {
|
||||
for entry in shown.iter().skip(range.start).take(range.len()) {
|
||||
ui.add(
|
||||
egui::Label::new(words::entry_line(entry, state.offset_minutes))
|
||||
@@ -213,11 +233,11 @@ pub fn history_view(
|
||||
}
|
||||
Tab::Days => {
|
||||
let rows = by_day(&shown, state.offset_minutes);
|
||||
grouped(ui, rows.iter().map(|(d, t)| (d.to_string(), t)));
|
||||
grouped(ui, state.tab, rows.iter().map(|(d, t)| (d.to_string(), t)));
|
||||
}
|
||||
Tab::Months => {
|
||||
let rows = by_month(&shown, state.offset_minutes);
|
||||
grouped(ui, rows.iter().map(|(m, t)| (m.to_string(), t)));
|
||||
grouped(ui, state.tab, rows.iter().map(|(m, t)| (m.to_string(), t)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,7 +246,7 @@ pub fn history_view(
|
||||
}
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui
|
||||
.add_enabled(!shown.is_empty(), egui::Button::new(EXPORT))
|
||||
.clicked()
|
||||
@@ -252,8 +272,9 @@ pub fn history_view(
|
||||
action
|
||||
}
|
||||
|
||||
fn grouped<'a>(ui: &mut Ui, rows: impl Iterator<Item = (String, &'a Totals)>) {
|
||||
fn grouped<'a>(ui: &mut Ui, tab: Tab, rows: impl Iterator<Item = (String, &'a Totals)>) {
|
||||
ScrollArea::vertical()
|
||||
.id_salt(("history-list", tab))
|
||||
.max_height(LIST_HEIGHT)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! A neutral setup surface. Hosts keep their own chrome and use the same SetupSession state.
|
||||
use craft_ai_client::{CheckedNative, SetupRequest, SetupSession, SetupTicket};
|
||||
use egui::Ui;
|
||||
use egui::{RichText, Ui};
|
||||
|
||||
/// What the host should do after a frame. `Work` runs off the UI thread; the others are plain host actions.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -32,6 +32,9 @@ pub fn native_model_setup(ui: &mut Ui, session: &mut SetupSession) -> Option<Nat
|
||||
let plan = session.plan().clone();
|
||||
ui.heading(format!("Set up {}", plan.feature().title));
|
||||
ui.label(&plan.feature().summary);
|
||||
if let Some(note) = &plan.route().note {
|
||||
ui.label(RichText::new(note).small().weak());
|
||||
}
|
||||
ui.label("The model choice is shared with every app that supports this tool.");
|
||||
if let Some(checked) = session.checked() {
|
||||
ui.label("Files checked. This model has not been tried yet.");
|
||||
|
||||
+429
-55
@@ -4,9 +4,15 @@
|
||||
//! ```ignore
|
||||
//! // when ai_status reports StatusAction::AddConnection:
|
||||
//! panel = Some(ConnectionPanel::new(ConnectionManager::standard()?));
|
||||
//! // eframe: logic runs even while the window is hidden, ui only when it is drawn.
|
||||
//! if let Some(p) = &mut panel {
|
||||
//! if let Some(event) = p.poll(ctx) {
|
||||
//! // Saved is reported by poll; Closed comes from ui below.
|
||||
//! }
|
||||
//! }
|
||||
//! // every frame while it is open:
|
||||
//! if let Some(p) = &mut panel {
|
||||
//! match p.show(ui) {
|
||||
//! match p.ui(ui) {
|
||||
//! Some(PanelEvent::Saved(_)) | Some(PanelEvent::Closed) => panel = None,
|
||||
//! None => {}
|
||||
//! }
|
||||
@@ -16,12 +22,16 @@
|
||||
//! It writes through [`ConnectionManager`], the same code the launcher's connections screen uses,
|
||||
//! so a connection added here appears in the launcher and in every other Craft app, and the other
|
||||
//! way round.
|
||||
//!
|
||||
//! `ui` only draws and records what was pressed; `poll` starts the test, writes the store and
|
||||
//! applies the answer. `show` runs both for hosts that drive the panel from one UI callback.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Duration;
|
||||
|
||||
use craft_ai_client::{ConnectionManager, SaveError, TestRunner, TestTarget};
|
||||
use craft_ai_core::{ConnectionId, DraftError, ErrorKind, Field};
|
||||
use egui::Ui;
|
||||
use craft_ai_core::{ConnectionDraft, ConnectionId, DraftError, ErrorKind, Field};
|
||||
use egui::{Context, Ui};
|
||||
|
||||
use crate::form::{ConnectionFormState, FormEvent, TestLine, connection_form};
|
||||
|
||||
@@ -34,14 +44,69 @@ pub enum PanelEvent {
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Work the UI asked for. It runs on the next poll, never in the paint callback.
|
||||
enum PanelCommand {
|
||||
StartTest {
|
||||
endpoint: String,
|
||||
draft: ConnectionDraft,
|
||||
generation: u64,
|
||||
},
|
||||
Save(ConnectionDraft),
|
||||
}
|
||||
|
||||
/// The endpoint and generation a test was started for, so a late answer that no longer describes
|
||||
/// what the form holds can be dropped.
|
||||
#[derive(Clone, Debug)]
|
||||
struct TestCapture {
|
||||
endpoint: String,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// The fields that decide what a test actually checks. A change to any of them makes an answer
|
||||
/// that is still on its way stale.
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct TestIdentity {
|
||||
endpoint: String,
|
||||
provider: String,
|
||||
key: String,
|
||||
uses_key: bool,
|
||||
key_header: String,
|
||||
}
|
||||
|
||||
impl TestIdentity {
|
||||
fn of(draft: &ConnectionDraft) -> Self {
|
||||
Self {
|
||||
endpoint: draft.endpoint.clone(),
|
||||
provider: draft.provider.clone(),
|
||||
key: draft.key.clone(),
|
||||
uses_key: draft.uses_key,
|
||||
key_header: draft.key_header.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel's state.
|
||||
pub struct ConnectionPanel {
|
||||
manager: ConnectionManager,
|
||||
runner: TestRunner,
|
||||
form: ConnectionFormState,
|
||||
/// The address the running test was started against, so an answer arriving after the address
|
||||
/// changed can be told from one that still describes it. `None` while no test is running.
|
||||
testing: Option<String>,
|
||||
testing: Option<TestCapture>,
|
||||
generation: u64,
|
||||
pending: VecDeque<PanelCommand>,
|
||||
events: VecDeque<PanelEvent>,
|
||||
/// The frame a Save was queued in. A forced second pass receives the same click again; the
|
||||
/// monotonic frame number keeps it from queuing a second store write.
|
||||
save_frame: Option<u64>,
|
||||
/// The frame a Cancel was reported in, so a host that draws twice in one frame cannot close
|
||||
/// the panel twice.
|
||||
cancel_frame: Option<u64>,
|
||||
}
|
||||
|
||||
/// What one form event did: an event for the host, and whether work is waiting for `poll`.
|
||||
#[derive(Default)]
|
||||
struct FormOutcome {
|
||||
event: Option<PanelEvent>,
|
||||
repaint: bool,
|
||||
}
|
||||
|
||||
impl ConnectionPanel {
|
||||
@@ -56,68 +121,202 @@ impl ConnectionPanel {
|
||||
manager,
|
||||
form,
|
||||
testing: None,
|
||||
generation: 0,
|
||||
pending: VecDeque::new(),
|
||||
events: VecDeque::new(),
|
||||
save_frame: None,
|
||||
cancel_frame: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the panel and handles what was pressed.
|
||||
pub fn show(&mut self, ui: &mut Ui) -> Option<PanelEvent> {
|
||||
if self.testing.is_some()
|
||||
&& let Some(answer) = self.runner.poll()
|
||||
&& let Some(tested) = self.testing.take()
|
||||
{
|
||||
// The address stays editable while a test runs, so this answer may describe an
|
||||
// address the form no longer holds. Editing already cleared the models and the
|
||||
// result line, leaving the form as untested; taking the answer now would put one
|
||||
// endpoint's models under another, so it is dropped instead.
|
||||
if tested.trim() == self.form.draft.endpoint.trim() {
|
||||
match answer {
|
||||
Ok(report) => {
|
||||
let models = report.models.len();
|
||||
if self.form.draft.set_models(&tested, report.models) {
|
||||
self.form.test = TestLine::Passed {
|
||||
models,
|
||||
millis: u64::try_from(report.elapsed.as_millis())
|
||||
.unwrap_or(u64::MAX),
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
if e.kind == ErrorKind::Unsupported
|
||||
&& self.form.draft.provider == "vertex-express" =>
|
||||
/// Runs the panel's work without drawing it. Call from `App::logic` so a running test still
|
||||
/// finishes while the window is hidden, and keep calling `ui` from `App::ui` while open.
|
||||
pub fn poll(&mut self, ctx: &Context) -> Option<PanelEvent> {
|
||||
if let Some(command) = self.pending.pop_front() {
|
||||
match command {
|
||||
PanelCommand::StartTest {
|
||||
endpoint,
|
||||
draft,
|
||||
generation,
|
||||
} => {
|
||||
let still_current = generation == self.generation
|
||||
&& self.testing.as_ref().is_some_and(|capture| {
|
||||
capture.endpoint == endpoint && capture.generation == generation
|
||||
})
|
||||
&& self.form.draft.endpoint.trim() == endpoint;
|
||||
if still_current {
|
||||
self.runner.start(TestTarget::Draft(draft));
|
||||
} else if self
|
||||
.testing
|
||||
.as_ref()
|
||||
.is_some_and(|capture| capture.generation == generation)
|
||||
{
|
||||
self.form.test = TestLine::Unverified(e.message);
|
||||
// The form changed after the click; do not start a test for old values and
|
||||
// do not leave the form showing "Testing" forever.
|
||||
self.cancel_test();
|
||||
}
|
||||
Err(e) => self.form.test = TestLine::Failed(e.message),
|
||||
}
|
||||
PanelCommand::Save(draft) => match self.manager.save(&draft) {
|
||||
Ok(saved) => self.events.push_back(PanelEvent::Saved(saved.id)),
|
||||
Err(SaveError::Invalid(e)) => self.form.error = Some(e),
|
||||
Err(SaveError::Failed(e)) => {
|
||||
self.form.error = Some(DraftError {
|
||||
field: Field::Label,
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if self.testing.is_some() {
|
||||
ui.ctx().request_repaint_after(Duration::from_millis(100));
|
||||
}
|
||||
match connection_form(ui, &mut self.form, self.testing.is_some())? {
|
||||
FormEvent::Cancel => Some(PanelEvent::Closed),
|
||||
FormEvent::Test => {
|
||||
self.form.test = TestLine::Running;
|
||||
self.form.error = None;
|
||||
self.testing = Some(self.form.draft.endpoint.trim().to_owned());
|
||||
self.runner
|
||||
.start(TestTarget::Draft(self.form.draft.clone()));
|
||||
None
|
||||
if let Some(answer) = self.runner.poll() {
|
||||
self.finish_test(answer);
|
||||
}
|
||||
FormEvent::Save => match self.manager.save(&self.form.draft) {
|
||||
Ok(saved) => Some(PanelEvent::Saved(saved.id)),
|
||||
Err(SaveError::Invalid(e)) => {
|
||||
self.form.error = Some(e);
|
||||
None
|
||||
// Hidden logic still ticks: the integration keeps calling poll until the answer is in.
|
||||
if self.testing.is_some() {
|
||||
ctx.request_repaint_after(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
self.events.pop_front()
|
||||
}
|
||||
|
||||
/// Draws the panel and records what was pressed. Side effects wait for [`Self::poll`].
|
||||
pub fn ui(&mut self, ui: &mut Ui) -> Option<PanelEvent> {
|
||||
let before = TestIdentity::of(&self.form.draft);
|
||||
let event = connection_form(ui, &mut self.form, self.testing.is_some());
|
||||
let after = TestIdentity::of(&self.form.draft);
|
||||
if before != after {
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
if self.testing.is_some() {
|
||||
// The running test describes values that are no longer on screen. Drop its answer
|
||||
// and let the person start a new test immediately.
|
||||
self.cancel_test();
|
||||
}
|
||||
}
|
||||
let outcome = self.handle_form_event(event?, ui.ctx().cumulative_frame_nr());
|
||||
if outcome.repaint {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
outcome.event
|
||||
}
|
||||
|
||||
/// Applies one press. `frame` is the monotonic frame number, so a host that draws twice in the
|
||||
/// same frame cannot queue the same side effect twice.
|
||||
fn handle_form_event(&mut self, event: FormEvent, frame: u64) -> FormOutcome {
|
||||
let mut outcome = FormOutcome::default();
|
||||
match event {
|
||||
FormEvent::Cancel => {
|
||||
if self.cancel_frame != Some(frame) {
|
||||
self.cancel_frame = Some(frame);
|
||||
outcome.event = Some(PanelEvent::Closed);
|
||||
}
|
||||
Err(SaveError::Failed(e)) => {
|
||||
self.form.error = Some(DraftError {
|
||||
field: Field::Label,
|
||||
message: e.message,
|
||||
}
|
||||
FormEvent::Test => {
|
||||
if self.testing.is_none()
|
||||
&& !self
|
||||
.pending
|
||||
.iter()
|
||||
.any(|command| matches!(command, PanelCommand::StartTest { .. }))
|
||||
{
|
||||
let endpoint = self.form.draft.endpoint.trim().to_owned();
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
// Set this now, not in poll: a forced second UI pass must see Test disabled
|
||||
// and must not queue a second start.
|
||||
self.form.test = TestLine::Running;
|
||||
self.form.error = None;
|
||||
self.testing = Some(TestCapture {
|
||||
endpoint: endpoint.clone(),
|
||||
generation: self.generation,
|
||||
});
|
||||
None
|
||||
self.pending.push_back(PanelCommand::StartTest {
|
||||
endpoint,
|
||||
draft: self.form.draft.clone(),
|
||||
generation: self.generation,
|
||||
});
|
||||
outcome.repaint = true;
|
||||
}
|
||||
},
|
||||
}
|
||||
FormEvent::Save => {
|
||||
if self.save_frame != Some(frame)
|
||||
&& !self
|
||||
.pending
|
||||
.iter()
|
||||
.any(|command| matches!(command, PanelCommand::Save(_)))
|
||||
{
|
||||
self.save_frame = Some(frame);
|
||||
self.pending
|
||||
.push_back(PanelCommand::Save(self.form.draft.clone()));
|
||||
outcome.repaint = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Stops waiting for a test, drops a start that was never admitted, and clears the running line.
|
||||
fn cancel_test(&mut self) {
|
||||
self.testing = None;
|
||||
self.pending
|
||||
.retain(|command| !matches!(command, PanelCommand::StartTest { .. }));
|
||||
if matches!(self.form.test, TestLine::Running) {
|
||||
self.form.test = TestLine::Idle;
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the answer only if it still describes the form and the test generation that is
|
||||
/// current. This is the guard that keeps a late answer for an old endpoint from replacing the
|
||||
/// models the form now shows.
|
||||
fn finish_test(&mut self, answer: Result<craft_ai_client::TestReport, craft_ai_core::AiError>) {
|
||||
let Some(capture) = self.testing.take() else {
|
||||
return;
|
||||
};
|
||||
if capture.generation == self.generation
|
||||
&& capture.endpoint == self.form.draft.endpoint.trim()
|
||||
{
|
||||
self.apply_answer(answer, &capture.endpoint);
|
||||
} else {
|
||||
self.cancel_test();
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the panel and handles what was pressed, for hosts that use one UI callback. Events
|
||||
/// keep their order: a save that finishes on the same frame as Cancel is reported before it.
|
||||
pub fn show(&mut self, ui: &mut Ui) -> Option<PanelEvent> {
|
||||
let polled = self.poll(ui.ctx());
|
||||
let drawn = self.ui(ui);
|
||||
if let Some(event) = polled {
|
||||
self.events.push_front(event);
|
||||
}
|
||||
if let Some(event) = drawn {
|
||||
self.events.push_back(event);
|
||||
}
|
||||
self.events.pop_front()
|
||||
}
|
||||
|
||||
fn apply_answer(
|
||||
&mut self,
|
||||
answer: Result<craft_ai_client::TestReport, craft_ai_core::AiError>,
|
||||
tested: &str,
|
||||
) {
|
||||
match answer {
|
||||
Ok(report) => {
|
||||
let models = report.models.len();
|
||||
if self.form.draft.set_models(tested, report.models) {
|
||||
self.form.test = TestLine::Passed {
|
||||
models,
|
||||
millis: u64::try_from(report.elapsed.as_millis()).unwrap_or(u64::MAX),
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
if e.kind == ErrorKind::Unsupported
|
||||
&& self.form.draft.provider == "vertex-express" =>
|
||||
{
|
||||
self.form.test = TestLine::Unverified(e.message);
|
||||
}
|
||||
Err(e) => self.form.test = TestLine::Failed(e.message),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,3 +325,178 @@ impl ConnectionPanel {
|
||||
&self.form
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use craft_ai_client::TestReport;
|
||||
use craft_ai_core::{ModelId, ModelInfo};
|
||||
use craft_ai_native::secrets::MemoryStore;
|
||||
use craft_ai_native::shared::SharedStore;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn panel() -> (tempfile::TempDir, ConnectionPanel) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let shared = SharedStore::new(dir.path().join("connections.json"));
|
||||
let manager = ConnectionManager::new(shared, Arc::new(MemoryStore::new()));
|
||||
(dir, ConnectionPanel::new(manager))
|
||||
}
|
||||
|
||||
fn answer(model: &str) -> Result<TestReport, craft_ai_core::AiError> {
|
||||
Ok(TestReport {
|
||||
models: vec![ModelInfo::new(
|
||||
ModelId::new(model).unwrap(),
|
||||
format!("{model} label"),
|
||||
)],
|
||||
elapsed: Duration::ZERO,
|
||||
probe: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A host can draw twice in one frame. The second draw sees the same press and must not queue
|
||||
/// a second start, save, or close.
|
||||
#[test]
|
||||
fn a_second_draw_in_one_frame_does_not_repeat_a_press() {
|
||||
let (_dir, mut panel) = panel();
|
||||
|
||||
let _ = panel.handle_form_event(FormEvent::Test, 12);
|
||||
let _ = panel.handle_form_event(FormEvent::Test, 12);
|
||||
assert_eq!(
|
||||
panel
|
||||
.pending
|
||||
.iter()
|
||||
.filter(|command| matches!(command, PanelCommand::StartTest { .. }))
|
||||
.count(),
|
||||
1,
|
||||
"one press, one queued test"
|
||||
);
|
||||
assert!(panel.testing.is_some(), "Test is disabled immediately");
|
||||
|
||||
assert_eq!(
|
||||
panel.handle_form_event(FormEvent::Cancel, 12).event,
|
||||
Some(PanelEvent::Closed)
|
||||
);
|
||||
assert_eq!(
|
||||
panel.handle_form_event(FormEvent::Cancel, 12).event,
|
||||
None,
|
||||
"the same Cancel press is not reported twice"
|
||||
);
|
||||
assert_eq!(
|
||||
panel.handle_form_event(FormEvent::Cancel, 13).event,
|
||||
Some(PanelEvent::Closed),
|
||||
"a new frame can close"
|
||||
);
|
||||
|
||||
let _ = panel.handle_form_event(FormEvent::Save, 14);
|
||||
let _ = panel.handle_form_event(FormEvent::Save, 14);
|
||||
assert_eq!(
|
||||
panel
|
||||
.pending
|
||||
.iter()
|
||||
.filter(|command| matches!(command, PanelCommand::Save(_)))
|
||||
.count(),
|
||||
1,
|
||||
"one press, one queued save"
|
||||
);
|
||||
}
|
||||
|
||||
/// `poll` consumes a queued Save before egui's forced second pass redraws the same click.
|
||||
/// The second draw must not enqueue the save again just because the queue is now empty.
|
||||
#[test]
|
||||
fn a_save_consumed_by_poll_is_still_deduplicated_within_the_same_frame() {
|
||||
let (_dir, mut panel) = panel();
|
||||
panel.form.draft = craft_ai_core::ConnectionDraft::from_preset(
|
||||
craft_ai_core::PRESETS
|
||||
.iter()
|
||||
.find(|preset| preset.key == "openai")
|
||||
.expect("OpenAI preset"),
|
||||
);
|
||||
panel.form.draft.key = "sk-test".to_owned();
|
||||
|
||||
let _ = panel.handle_form_event(FormEvent::Save, 21);
|
||||
let first = panel.poll(&Context::default());
|
||||
assert!(
|
||||
matches!(first, Some(PanelEvent::Saved(_))),
|
||||
"the first Save is consumed by poll: {first:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
panel.manager.views().unwrap().len(),
|
||||
1,
|
||||
"poll wrote the first Save"
|
||||
);
|
||||
|
||||
// The same click arrives again after poll drained the queue, still in frame 21.
|
||||
let _ = panel.handle_form_event(FormEvent::Save, 21);
|
||||
let second = panel.poll(&Context::default());
|
||||
assert!(
|
||||
second.is_none(),
|
||||
"a Save already consumed in this frame must not be saved again: {second:?}"
|
||||
);
|
||||
assert!(
|
||||
panel.pending.is_empty(),
|
||||
"the same frame must not queue another store write"
|
||||
);
|
||||
assert_eq!(
|
||||
panel.manager.views().unwrap().len(),
|
||||
1,
|
||||
"the same-frame redraw must not write a second connection"
|
||||
);
|
||||
|
||||
// Frame numbers are monotonic; a genuinely new press in the next frame is allowed.
|
||||
let _ = panel.handle_form_event(FormEvent::Save, 22);
|
||||
let next = panel.poll(&Context::default());
|
||||
assert!(
|
||||
matches!(next, Some(PanelEvent::Saved(_))),
|
||||
"a new frame may save again: {next:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The panel's own answer guard, exercised without starting a second `TestRunner`. A changed
|
||||
/// endpoint and a changed generation each make an answer stale on their own.
|
||||
#[test]
|
||||
fn a_stale_answer_is_dropped_by_the_panel_guard() {
|
||||
for (draft_endpoint, capture_generation) in
|
||||
[("http://new/v1", 1u64), ("http://old/v1", 0u64)]
|
||||
{
|
||||
let (_dir, mut panel) = panel();
|
||||
panel.form.draft.endpoint = draft_endpoint.to_owned();
|
||||
panel.generation = 1;
|
||||
panel.form.test = TestLine::Running;
|
||||
panel.testing = Some(TestCapture {
|
||||
endpoint: "http://old/v1".to_owned(),
|
||||
generation: capture_generation,
|
||||
});
|
||||
|
||||
panel.finish_test(answer("old-model"));
|
||||
|
||||
assert!(
|
||||
panel.form.draft.models.is_empty(),
|
||||
"a stale answer must not supply models: {draft_endpoint}, {capture_generation}"
|
||||
);
|
||||
assert!(panel.testing.is_none());
|
||||
assert!(matches!(panel.form.test, TestLine::Idle));
|
||||
}
|
||||
}
|
||||
|
||||
/// If a queued start is rejected, the form must not stay on "Testing" with Test disabled.
|
||||
#[test]
|
||||
fn a_rejected_queued_start_clears_the_running_line() {
|
||||
let (_dir, mut panel) = panel();
|
||||
panel.form.draft.endpoint = "http://new/v1".to_owned();
|
||||
panel.generation = 1;
|
||||
panel.form.test = TestLine::Running;
|
||||
panel.testing = Some(TestCapture {
|
||||
endpoint: "http://old/v1".to_owned(),
|
||||
generation: 0,
|
||||
});
|
||||
panel.pending.push_back(PanelCommand::StartTest {
|
||||
endpoint: "http://old/v1".to_owned(),
|
||||
draft: panel.form.draft.clone(),
|
||||
generation: 0,
|
||||
});
|
||||
|
||||
assert!(panel.poll(&Context::default()).is_none());
|
||||
assert!(panel.testing.is_none());
|
||||
assert!(matches!(panel.form.test, TestLine::Idle));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ fn setup_label(group: &PickerGroup) -> Option<&'static str> {
|
||||
fn group_ui(ui: &mut Ui, group: &PickerGroup) -> Option<PickerAction> {
|
||||
let mut action = None;
|
||||
let ready = group.state == GroupState::Ready;
|
||||
ui.horizontal(|ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let title = RichText::new(&group.title).strong();
|
||||
ui.label(if ready { title } else { title.weak() });
|
||||
if let Some(locality) = group.locality {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! with no percentage) and a two-minute video job (a bar), and shows the final state afterwards.
|
||||
|
||||
use craft_ai_core::{CostEstimate, JobState};
|
||||
use egui::{ProgressBar, Ui};
|
||||
use egui::{Align, InnerResponse, Layout, ProgressBar, TextStyle, Ui};
|
||||
|
||||
use crate::text;
|
||||
|
||||
@@ -23,35 +23,116 @@ pub fn job_progress(
|
||||
cost: Option<&CostEstimate>,
|
||||
) -> Option<ProgressAction> {
|
||||
let mut action = None;
|
||||
// `ui.vertical` resets the layout direction to left-to-right, so remember the host's
|
||||
// direction before entering it and use it for the rows below.
|
||||
let right_to_left = ui.layout().prefer_right_to_left();
|
||||
ui.vertical(|ui| {
|
||||
let label = text::state_label(state);
|
||||
ui.horizontal(|ui| {
|
||||
match text::progress_fraction(state) {
|
||||
Some(f) => {
|
||||
// Leave room for the Cancel button; a bar left to fill the row would push it
|
||||
// out of sight.
|
||||
let width = (ui.available_width() - 90.0).max(80.0);
|
||||
let cancel_width = button_width(ui, CANCEL);
|
||||
let spacing = ui.spacing().item_spacing.x;
|
||||
let available = ui.available_width();
|
||||
let cancel = !state.is_terminal();
|
||||
let bar_width = if cancel {
|
||||
(available - cancel_width - spacing).max(80.0)
|
||||
} else {
|
||||
available
|
||||
};
|
||||
let stack = cancel && available - cancel_width - spacing < 80.0;
|
||||
match text::progress_fraction(state) {
|
||||
Some(f) => {
|
||||
let bar = |ui: &mut Ui, width: f32| {
|
||||
ui.add(
|
||||
ProgressBar::new(f)
|
||||
.text(label.clone())
|
||||
.desired_width(width)
|
||||
.animate(false),
|
||||
);
|
||||
};
|
||||
if stack {
|
||||
bar(ui, available);
|
||||
if ui.button(CANCEL).clicked() {
|
||||
action = Some(ProgressAction::Cancel);
|
||||
}
|
||||
} else {
|
||||
row(ui, right_to_left, |ui| {
|
||||
bar(ui, bar_width);
|
||||
if cancel && ui.button(CANCEL).clicked() {
|
||||
action = Some(ProgressAction::Cancel);
|
||||
}
|
||||
});
|
||||
}
|
||||
None => {
|
||||
}
|
||||
None => {
|
||||
let label_width = ui
|
||||
.painter()
|
||||
.layout_no_wrap(
|
||||
label.clone(),
|
||||
TextStyle::Body.resolve(ui.style()),
|
||||
ui.visuals().text_color(),
|
||||
)
|
||||
.size()
|
||||
.x;
|
||||
let spinner_width = if cancel {
|
||||
ui.spacing().interact_size.y + spacing
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let needed =
|
||||
spinner_width + label_width + if cancel { spacing + cancel_width } else { 0.0 };
|
||||
let stack = cancel && needed > available;
|
||||
if stack {
|
||||
if !state.is_terminal() {
|
||||
ui.spinner();
|
||||
}
|
||||
ui.label(label.clone());
|
||||
if ui.button(CANCEL).clicked() {
|
||||
action = Some(ProgressAction::Cancel);
|
||||
}
|
||||
} else {
|
||||
row(ui, right_to_left, |ui| {
|
||||
if !state.is_terminal() {
|
||||
ui.spinner();
|
||||
}
|
||||
ui.label(label.clone());
|
||||
if cancel && ui.button(CANCEL).clicked() {
|
||||
action = Some(ProgressAction::Cancel);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if !state.is_terminal() && ui.button(CANCEL).clicked() {
|
||||
action = Some(ProgressAction::Cancel);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(c) = cost {
|
||||
ui.label(text::cost_text(c));
|
||||
}
|
||||
});
|
||||
action
|
||||
}
|
||||
|
||||
/// A horizontal row that follows the host's direction. `Ui::horizontal` would choose from the
|
||||
/// vertical scope's layout, which loses the right-to-left direction of the host.
|
||||
fn row<R>(
|
||||
ui: &mut Ui,
|
||||
right_to_left: bool,
|
||||
add_contents: impl FnOnce(&mut Ui) -> R,
|
||||
) -> InnerResponse<R> {
|
||||
// `with_layout` claims all remaining vertical space on a right-to-left host, so the row
|
||||
// would be vertically centered in the whole panel. Allocate a one-line-tall row instead,
|
||||
// matching `Ui::horizontal`, while keeping the host's horizontal direction.
|
||||
let layout = if right_to_left {
|
||||
Layout::right_to_left(Align::Center)
|
||||
} else {
|
||||
Layout::left_to_right(Align::Center)
|
||||
};
|
||||
let size = egui::vec2(ui.available_width(), ui.spacing().interact_size.y);
|
||||
ui.allocate_ui_with_layout(size, layout, add_contents)
|
||||
}
|
||||
|
||||
/// The width a labelled button needs at this style, including its padding.
|
||||
fn button_width(ui: &Ui, label: &str) -> f32 {
|
||||
let galley = ui.painter().layout_no_wrap(
|
||||
label.to_owned(),
|
||||
TextStyle::Button.resolve(ui.style()),
|
||||
ui.visuals().text_color(),
|
||||
);
|
||||
galley.size().x + 2.0 * ui.spacing().button_padding.x
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ pub fn ai_status(ui: &mut Ui, view: &StatusView<'_>) -> Option<StatusAction> {
|
||||
let mut action = None;
|
||||
ui.vertical(|ui| {
|
||||
ui.label(text::status_line(view.label, view.status));
|
||||
ui.horizontal(|ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if matches!(view.status, RoleStatus::Unavailable { .. })
|
||||
&& ui.button(OPEN_LAUNCHER).clicked()
|
||||
{
|
||||
@@ -100,7 +100,9 @@ pub fn ai_status(ui: &mut Ui, view: &StatusView<'_>) -> Option<StatusAction> {
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.accessible_name(format!("Model for {}", view.label));
|
||||
}
|
||||
if view.has_override && ui.button(USE_DEFAULT).clicked() {
|
||||
action = Some(StatusAction::UseDefault);
|
||||
|
||||
@@ -6,7 +6,7 @@ use craft_ai_core::{ErrorKind, HistoryLog, HistoryRecord, Locality, Outcome};
|
||||
use craft_ai_egui::history_text::entry_line;
|
||||
use craft_ai_egui::{
|
||||
CLEAR, CLEAR_NO, CLEAR_YES, EMPTY, EXPORT, HistoryAction, HistoryViewState, NOTHING_MATCHES,
|
||||
REFRESH, Range, TAB_DAYS, TAB_MONTHS, Tab, history_view,
|
||||
REFRESH, Range, TAB_DAYS, TAB_JOBS, TAB_MONTHS, Tab, history_view,
|
||||
};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
@@ -286,3 +286,149 @@ fn a_long_log_draws_only_the_rows_in_view() {
|
||||
assert!(h.query_by_label(&oldest).is_none());
|
||||
h.get_by_label("3000 jobs: 3000 done.");
|
||||
}
|
||||
|
||||
struct SizedState {
|
||||
view: HistoryViewState,
|
||||
log: HistoryLog,
|
||||
now: u64,
|
||||
action: Option<HistoryAction>,
|
||||
row_height: f32,
|
||||
spacing: f32,
|
||||
}
|
||||
|
||||
fn large_font_harness() -> Harness<'static, SizedState> {
|
||||
let jobs: Vec<Job> = (0..40u64)
|
||||
.map(|i| {
|
||||
let run: &'static str = Box::leak(format!("r{i}").into_boxed_str());
|
||||
let model: &'static str = Box::leak(format!("m{i:02}").into_boxed_str());
|
||||
Job {
|
||||
run,
|
||||
at: T0 + i * 60_000,
|
||||
app: "photocraft",
|
||||
model,
|
||||
outcome: Some(Outcome::Done),
|
||||
cost: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let log = log(&jobs, 0);
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(320.0, 700.0))
|
||||
.build_ui_state(
|
||||
|ui, state: &mut SizedState| {
|
||||
ui.style_mut()
|
||||
.text_styles
|
||||
.insert(egui::TextStyle::Body, egui::FontId::proportional(26.0));
|
||||
state.row_height = ui.text_style_height(&egui::TextStyle::Body);
|
||||
state.spacing = ui.spacing().item_spacing.y;
|
||||
if let Some(action) = history_view(ui, &mut state.view, &state.log, state.now) {
|
||||
state.action = Some(action);
|
||||
}
|
||||
},
|
||||
SizedState {
|
||||
view: HistoryViewState::default(),
|
||||
log,
|
||||
now: T0 + 40 * 60_000,
|
||||
action: None,
|
||||
row_height: 0.0,
|
||||
spacing: 0.0,
|
||||
},
|
||||
);
|
||||
h.run();
|
||||
h
|
||||
}
|
||||
|
||||
/// The visible job rows, in screen order, with the model index parsed from each line.
|
||||
fn visible_rows(h: &Harness<'_, SizedState>) -> Vec<(usize, egui::Rect)> {
|
||||
let mut rows: Vec<(usize, egui::Rect)> = h
|
||||
.query_all_by_label_contains("photocraft")
|
||||
.filter_map(|node| {
|
||||
// egui puts a Label's text in AccessKit's value; `By::label` handles this too.
|
||||
let accessibility = node.accesskit_node();
|
||||
let label = accessibility.value().or_else(|| accessibility.label())?;
|
||||
let part = label.split(" \u{b7} ").nth(3)?;
|
||||
let model = part.split(' ').next()?;
|
||||
let index = model.strip_prefix('m')?.parse().ok()?;
|
||||
Some((index, node.rect()))
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| a.1.top().total_cmp(&b.1.top()));
|
||||
rows
|
||||
}
|
||||
|
||||
fn scroll_rows(h: &mut Harness<'_, SizedState>, times: usize) {
|
||||
for _ in 0..times {
|
||||
let node = h
|
||||
.query_all_by_label_contains("photocraft")
|
||||
.last()
|
||||
.expect("a visible row to scroll from");
|
||||
node.scroll_down();
|
||||
for _ in 0..12 {
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_use_one_text_height_without_double_spacing_at_a_larger_font() {
|
||||
let mut h = large_font_harness();
|
||||
let first = visible_rows(&h);
|
||||
assert!(
|
||||
first.len() > 1,
|
||||
"the fixture has visible rows; labels: {:?}",
|
||||
h.root()
|
||||
);
|
||||
// History is newest first: the fixture's model indices descend as we scroll.
|
||||
assert_eq!(first.first().unwrap().0, 39);
|
||||
assert!(first.last().unwrap().0 > 0);
|
||||
let expected = h.state().row_height + h.state().spacing;
|
||||
assert!(
|
||||
expected > 20.0,
|
||||
"the test uses a really larger font: {expected}"
|
||||
);
|
||||
for pair in first.windows(2) {
|
||||
assert_eq!(
|
||||
pair[0].0,
|
||||
pair[1].0 + 1,
|
||||
"adjacent jobs remain in history order"
|
||||
);
|
||||
let delta = pair[1].1.top() - pair[0].1.top();
|
||||
assert!(
|
||||
(delta - expected).abs() < 1.5,
|
||||
"row spacing is {delta}, expected one text height plus item spacing ({expected})"
|
||||
);
|
||||
}
|
||||
|
||||
scroll_rows(&mut h, 4);
|
||||
let middle = visible_rows(&h);
|
||||
assert!(!middle.is_empty());
|
||||
assert!(middle.first().unwrap().0 < 39, "the newest row is gone");
|
||||
assert!(
|
||||
middle.last().unwrap().0 > 0,
|
||||
"the oldest row is not reached yet"
|
||||
);
|
||||
|
||||
scroll_rows(&mut h, 40);
|
||||
let end = visible_rows(&h);
|
||||
assert_eq!(end.last().unwrap().0, 0);
|
||||
assert!(end.first().unwrap().0 < 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_filter_is_named_by_what_it_filters_not_by_its_current_value() {
|
||||
let mut h = harness(HistoryViewState::default(), two_apps(), T0 + 5000);
|
||||
h.run();
|
||||
for name in ["Filter by app", "Filter by model", "Date range"] {
|
||||
let node = h.get_by_label(name);
|
||||
assert_eq!(
|
||||
node.accesskit_node().role(),
|
||||
egui::accesskit::Role::ComboBox
|
||||
);
|
||||
}
|
||||
assert!(h.unnamed_widgets().is_empty());
|
||||
h.get_by_label(TAB_DAYS).click();
|
||||
h.run();
|
||||
h.get_by_label(TAB_JOBS).click();
|
||||
h.run();
|
||||
assert_eq!(h.state().view.tab, Tab::Jobs);
|
||||
}
|
||||
|
||||
@@ -257,3 +257,40 @@ fn a_download_error_is_actionable_and_does_not_echo_a_sources_message() {
|
||||
);
|
||||
assert!(h.query_by_label("Use this model").is_none());
|
||||
}
|
||||
|
||||
/// A route's one-line advice is shown before the person approves anything.
|
||||
#[test]
|
||||
fn the_routes_advice_is_shown_with_the_plan() {
|
||||
let (dir, desk, _) = fixtures::world();
|
||||
let mut factory = ProviderFactory::standard();
|
||||
factory.register("candle", |_, _| {
|
||||
Err(AiError::unsupported("setup does not run inference"))
|
||||
});
|
||||
let client = AiClient::new(
|
||||
AppId::new("test-app").unwrap(),
|
||||
SharedStore::new(dir.path().join("connections.json")),
|
||||
Arc::new(MemoryStore::new()),
|
||||
)
|
||||
.with_factory(factory);
|
||||
let mut feature = fixtures::feature();
|
||||
fixtures::candle_mut(&mut feature).note =
|
||||
Some("Selects by clicking on the picture, not by a description.".into());
|
||||
let plan = desk
|
||||
.prepare_native_setup(&feature, "candle", &client)
|
||||
.unwrap();
|
||||
let mut h = Harness::new_ui_state(
|
||||
|ui, state: &mut State| {
|
||||
if let Some(action) = native_model_setup(ui, &mut state.session) {
|
||||
state.last = Some(action);
|
||||
}
|
||||
},
|
||||
State {
|
||||
_dir: dir,
|
||||
session: craft_ai_client::SetupSession::new(plan),
|
||||
last: None,
|
||||
},
|
||||
);
|
||||
h.run();
|
||||
h.get_by_label("Selects by clicking on the picture, not by a description.");
|
||||
h.get_by_label("The model choice is shared with every app that supports this tool.");
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use craft_ai_client::ConnectionManager;
|
||||
use craft_ai_core::ConnectionId;
|
||||
use craft_ai_egui::{ConnectionPanel, PanelEvent};
|
||||
use craft_ai_egui::{ConnectionPanel, PanelEvent, TestLine};
|
||||
use craft_ai_native::secrets::{MemoryStore, SecretStore};
|
||||
use craft_ai_native::shared::SharedStore;
|
||||
use egui::accesskit::Role;
|
||||
use egui::accesskit::{Role, Toggled};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use support::{Reply, Server};
|
||||
|
||||
const KEY: &str = "sk-panel-key-777";
|
||||
@@ -22,6 +22,9 @@ const KEY: &str = "sk-panel-key-777";
|
||||
struct State {
|
||||
panel: ConnectionPanel,
|
||||
event: Option<PanelEvent>,
|
||||
events: Vec<PanelEvent>,
|
||||
/// Ask egui to run one more pass in the same frame, so a click's event is seen twice.
|
||||
force_multipass: bool,
|
||||
}
|
||||
|
||||
struct Rig {
|
||||
@@ -32,19 +35,30 @@ struct Rig {
|
||||
}
|
||||
|
||||
fn rig() -> Rig {
|
||||
rig_with(false)
|
||||
}
|
||||
|
||||
fn rig_with(force_multipass: bool) -> Rig {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let shared = SharedStore::new(dir.path().join("connections.json"));
|
||||
let secrets = Arc::new(MemoryStore::new());
|
||||
let manager = ConnectionManager::new(shared.clone(), secrets.clone());
|
||||
let h = Harness::new_ui_state(
|
||||
|ui, s: &mut State| {
|
||||
if s.force_multipass {
|
||||
s.force_multipass = false;
|
||||
ui.ctx().request_discard("test: force another UI pass");
|
||||
}
|
||||
if let Some(e) = s.panel.show(ui) {
|
||||
s.events.push(e.clone());
|
||||
s.event = Some(e);
|
||||
}
|
||||
},
|
||||
State {
|
||||
panel: ConnectionPanel::new(manager),
|
||||
event: None,
|
||||
events: Vec::new(),
|
||||
force_multipass,
|
||||
},
|
||||
);
|
||||
Rig {
|
||||
@@ -129,7 +143,7 @@ fn a_form_problem_stays_on_screen_and_saves_nothing() {
|
||||
r.h.run();
|
||||
r.h.get_by_label("Save").click();
|
||||
r.h.run();
|
||||
r.h.get_by_label_contains("Paste the key");
|
||||
r.h.get_by_role(Role::PasswordInput);
|
||||
assert!(r.h.state().event.is_none());
|
||||
assert!(r.shared.load().unwrap().connections.is_empty());
|
||||
// A key with a space in it is refused, and the message does not repeat it.
|
||||
@@ -292,3 +306,225 @@ fn models_from_a_test_that_was_still_running_are_not_saved_for_the_new_address()
|
||||
"those models were found at the address that was replaced"
|
||||
);
|
||||
}
|
||||
|
||||
fn choose_ollama_and_address(h: &mut Harness<'_, State>, address: &str) {
|
||||
h.get_by_role(Role::ComboBox).click();
|
||||
h.run();
|
||||
h.get_by_label("Ollama").click();
|
||||
h.run();
|
||||
replace_text(h, 1, address);
|
||||
}
|
||||
|
||||
fn named_models_server(models: &[&str], delay_ms: u64) -> Server {
|
||||
let entries: Vec<String> = models
|
||||
.iter()
|
||||
.map(|model| format!(r#"{{"id":"{model}"}}"#))
|
||||
.collect();
|
||||
let body = format!(r#"{{"object":"list","data":[{}]}}"#, entries.join(","));
|
||||
Server::start(move |_| {
|
||||
if delay_ms > 0 {
|
||||
std::thread::sleep(Duration::from_millis(delay_ms));
|
||||
}
|
||||
Reply::Json(200, body.clone())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_field_has_its_visible_name_and_role_and_the_key_goes_away_when_not_used() {
|
||||
let mut r = rig();
|
||||
r.h.run();
|
||||
let fields: Vec<(Role, Option<String>)> =
|
||||
r.h.get_all_by_role(Role::TextInput)
|
||||
.map(|node| (node.accesskit_node().role(), node.accesskit_node().label()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
fields,
|
||||
[
|
||||
(Role::TextInput, Some("Name".to_owned())),
|
||||
(Role::TextInput, Some("Address".to_owned())),
|
||||
]
|
||||
);
|
||||
let key = r.h.get_by_role(Role::PasswordInput);
|
||||
assert_eq!(key.accesskit_node().label().as_deref(), Some("Key"));
|
||||
|
||||
let uses_key = r.h.get_by_label("Uses a key");
|
||||
assert_eq!(uses_key.accesskit_node().role(), Role::CheckBox);
|
||||
assert_eq!(uses_key.accesskit_node().toggled(), Some(Toggled::True));
|
||||
assert!(r.h.unnamed_widgets().is_empty());
|
||||
|
||||
uses_key.click();
|
||||
r.h.run();
|
||||
assert!(r.h.query_by_role(Role::PasswordInput).is_none());
|
||||
assert!(r.h.unnamed_widgets().is_empty());
|
||||
|
||||
for label in ["Save", "Test", "Cancel"] {
|
||||
assert_eq!(
|
||||
r.h.get_by_label(label).accesskit_node().role(),
|
||||
Role::Button,
|
||||
"{label}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_disabled_immediately_and_reports_the_running_state() {
|
||||
let (release, gate) = std::sync::mpsc::channel::<()>();
|
||||
let gate = std::sync::Mutex::new(gate);
|
||||
let server = Server::start(move |_| {
|
||||
// Completion is controlled by the test, independent of host scheduling speed.
|
||||
let _ = gate.lock().unwrap().recv_timeout(Duration::from_secs(10));
|
||||
Reply::Json(
|
||||
200,
|
||||
r#"{"object":"list","data":[{"id":"gpt-4o"},{"id":"gpt-image-1"}]}"#.into(),
|
||||
)
|
||||
});
|
||||
let mut r = rig();
|
||||
r.h.run();
|
||||
choose_ollama_and_address(&mut r.h, &server.url);
|
||||
r.h.get_by_label("Test").click();
|
||||
r.h.step();
|
||||
assert!(matches!(r.h.state().panel.form().test, TestLine::Running));
|
||||
// The click changes state after the button's current-pass accessibility node is built.
|
||||
r.h.step();
|
||||
assert!(r.h.get_by_label("Test").accesskit_node().is_disabled());
|
||||
release.send(()).unwrap();
|
||||
wait_until(&mut r.h, "Connected. Models found: 2");
|
||||
assert!(!r.h.get_by_label("Test").accesskit_node().is_disabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_click_tests_and_saves_once_when_egui_runs_a_second_pass() {
|
||||
let server = models_server();
|
||||
let mut r = rig_with(true);
|
||||
r.h.run();
|
||||
choose_ollama_and_address(&mut r.h, &server.url);
|
||||
|
||||
r.h.state_mut().force_multipass = true;
|
||||
r.h.get_by_label("Test").click();
|
||||
r.h.step();
|
||||
wait_until(&mut r.h, "Connected. Models found: 2");
|
||||
assert_eq!(server.count(), 1, "one click, one test request");
|
||||
assert!(
|
||||
r.h.state()
|
||||
.events
|
||||
.iter()
|
||||
.all(|event| !matches!(event, PanelEvent::Saved(_))),
|
||||
"testing saves nothing"
|
||||
);
|
||||
|
||||
r.h.state_mut().force_multipass = true;
|
||||
r.h.get_by_label("Save").click();
|
||||
r.h.step();
|
||||
r.h.step();
|
||||
let saved =
|
||||
r.h.state()
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| matches!(event, PanelEvent::Saved(_)))
|
||||
.count();
|
||||
assert_eq!(saved, 1, "one click, one save event");
|
||||
assert_eq!(r.shared.load().unwrap().connections.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_poll_finishes_the_test_without_another_ui_pass() {
|
||||
let server = models_server();
|
||||
let mut r = rig();
|
||||
r.h.run();
|
||||
choose_ollama_and_address(&mut r.h, &server.url);
|
||||
r.h.get_by_label("Test").click();
|
||||
r.h.step();
|
||||
let passes_before = r.h.ctx.cumulative_pass_nr();
|
||||
let ctx = r.h.ctx.clone();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
let state = r.h.state_mut();
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| {
|
||||
if let Some(event) = state.panel.poll(ctx) {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
if matches!(state.panel.form().test, TestLine::Passed { models: 2, .. }) {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"the hidden poll never got the answer"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert_eq!(
|
||||
ctx.cumulative_pass_nr(),
|
||||
passes_before,
|
||||
"run_logic must not run a UI pass"
|
||||
);
|
||||
assert!(events.is_empty(), "a passing test reports no panel event");
|
||||
assert_eq!(server.count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_answer_for_the_old_endpoint_cannot_replace_the_new_tests_models() {
|
||||
let old = named_models_server(&["old-1", "old-2"], 600);
|
||||
let new = named_models_server(&["new-1"], 0);
|
||||
let mut r = rig();
|
||||
r.h.run();
|
||||
choose_ollama_and_address(&mut r.h, &old.url);
|
||||
r.h.get_by_label("Test").click();
|
||||
r.h.step();
|
||||
|
||||
// Point the form at another endpoint while the first answer is still on its way, then test
|
||||
// the new one. The old answer has a different generation and must be dropped.
|
||||
replace_text_mid_test(&mut r.h, 1, &new.url);
|
||||
assert!(matches!(r.h.state().panel.form().test, TestLine::Idle));
|
||||
r.h.get_by_label("Test").click();
|
||||
r.h.step();
|
||||
wait_until(&mut r.h, "Connected. Models found: 1");
|
||||
|
||||
// Give the slow first server time to answer, then let any stale answer arrive.
|
||||
std::thread::sleep(Duration::from_millis(800));
|
||||
r.h.step();
|
||||
assert!(
|
||||
matches!(
|
||||
r.h.state().panel.form().test,
|
||||
TestLine::Passed { models: 1, .. }
|
||||
),
|
||||
"the answer for the replaced endpoint must not win"
|
||||
);
|
||||
assert_eq!(old.count(), 1);
|
||||
assert_eq!(new.count(), 1);
|
||||
|
||||
r.h.get_by_label("Save").click();
|
||||
r.h.run();
|
||||
let saved = r.shared.load().unwrap();
|
||||
assert_eq!(
|
||||
saved.connections[0].endpoint.as_deref(),
|
||||
Some(new.url.as_str())
|
||||
);
|
||||
assert_eq!(saved.connections[0].models.len(), 1);
|
||||
assert_eq!(saved.connections[0].models[0].id.as_str(), "new-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_that_finishes_as_cancel_is_pressed_is_reported_before_closed() {
|
||||
let mut r = rig();
|
||||
r.h.run();
|
||||
type_into(&mut r.h, Role::PasswordInput, 0, KEY);
|
||||
r.h.get_by_label("Save").click();
|
||||
// Queue the save without giving poll a frame to finish it yet.
|
||||
r.h.step();
|
||||
r.h.get_by_label("Cancel").click();
|
||||
r.h.step();
|
||||
assert!(
|
||||
matches!(r.h.state().events.first(), Some(PanelEvent::Saved(_))),
|
||||
"the save is reported before any close: {:?}",
|
||||
r.h.state().events
|
||||
);
|
||||
r.h.step();
|
||||
let events = &r.h.state().events;
|
||||
assert_eq!(events.len(), 2, "both events are delivered: {events:?}");
|
||||
assert!(matches!(events[0], PanelEvent::Saved(_)));
|
||||
assert_eq!(events[1], PanelEvent::Closed);
|
||||
assert!(matches!(r.h.state().event, Some(PanelEvent::Closed)));
|
||||
assert_eq!(r.shared.load().unwrap().connections.len(), 1);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ use craft_ai_egui::{
|
||||
ADD_KEY, ALL_MODELS, HEADING, IN_USE, NOTHING, PickerAction, SET_UP, SUGGESTED, SWITCH_ON,
|
||||
model_picker,
|
||||
};
|
||||
use egui::Role;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
|
||||
struct State {
|
||||
view: PickerView,
|
||||
@@ -49,6 +50,19 @@ fn view(groups: Vec<PickerGroup>, current: Option<CurrentChoice>) -> PickerView
|
||||
}
|
||||
}
|
||||
|
||||
fn harness_sized(size: egui::Vec2, view: PickerView) -> Harness<'static, State> {
|
||||
let mut h = Harness::builder().with_size(size).build_ui_state(
|
||||
|ui, st: &mut State| {
|
||||
if let Some(a) = model_picker(ui, &st.view) {
|
||||
st.last = Some(a);
|
||||
}
|
||||
},
|
||||
State { view, last: None },
|
||||
);
|
||||
h.run();
|
||||
h
|
||||
}
|
||||
|
||||
fn harness(view: PickerView) -> Harness<'static, State> {
|
||||
let mut h = Harness::new_ui_state(
|
||||
|ui, st: &mut State| {
|
||||
@@ -318,3 +332,60 @@ fn with_no_presets_there_is_no_suggested_section_and_no_extra_heading() {
|
||||
assert!(h.query_by_label(SUGGESTED).is_none());
|
||||
assert!(h.query_by_label(ALL_MODELS).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_model_is_reported_as_selected_and_setup_is_an_ordinary_button() {
|
||||
let mut group = group(
|
||||
"Box",
|
||||
GroupState::Ready,
|
||||
vec![
|
||||
row("box", "a", "Alpha", true),
|
||||
row("box", "b", "Beta", false),
|
||||
],
|
||||
);
|
||||
group.setup = Some(SetupLink::New {
|
||||
option: "local:select-subject".into(),
|
||||
});
|
||||
let h = harness(view(vec![group], None));
|
||||
let selected = h.get_by_label_contains("About Alpha.");
|
||||
assert_eq!(selected.accesskit_node().role(), Role::Button);
|
||||
assert_eq!(
|
||||
selected.accesskit_node().toggled(),
|
||||
Some(egui::accesskit::Toggled::True)
|
||||
);
|
||||
let other = h.get_by_label_contains("About Beta.");
|
||||
assert_eq!(
|
||||
other.accesskit_node().toggled(),
|
||||
Some(egui::accesskit::Toggled::False)
|
||||
);
|
||||
assert_eq!(h.get_by_label(SET_UP).accesskit_node().role(), Role::Button);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_provider_header_wraps_its_setup_button_into_a_narrow_window() {
|
||||
let mut group = group(
|
||||
"A provider with a name long enough to fill a narrow window",
|
||||
GroupState::NotSetUp,
|
||||
vec![row("box", "a", "Alpha", false)],
|
||||
);
|
||||
group.suggested = true;
|
||||
group.size_bytes = Some(32_000_000_000);
|
||||
group.note = "A long note that also wants room on the row.".into();
|
||||
group.setup = Some(SetupLink::New {
|
||||
option: "local:select-subject".into(),
|
||||
});
|
||||
let mut h = harness_sized(egui::vec2(180.0, 600.0), view(vec![group], None));
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(180.0, 600.0));
|
||||
let setup = h.get_by_label(SET_UP);
|
||||
assert!(
|
||||
screen.contains_rect(setup.rect()),
|
||||
"Set up is outside {screen:?}: {:?}",
|
||||
setup.rect()
|
||||
);
|
||||
setup.click();
|
||||
h.run();
|
||||
assert!(matches!(
|
||||
h.state().last,
|
||||
Some(PickerAction::SetUp(SetupLink::New { .. }))
|
||||
));
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,403 @@
|
||||
//! Deterministic visual snapshots for the shared widgets.
|
||||
//!
|
||||
//! The fixtures are synthetic and local: no provider, download, keychain or clock read. egui runs
|
||||
//! with its bundled test fonts, a fixed logical viewport, kittest's animations disabled and a fixed
|
||||
//! step clock. The parent generates and reviews the PNGs with a managed CPU-rendered run; this file
|
||||
//! intentionally carries no hand-made golden image.
|
||||
//!
|
||||
//! Each test also checks the semantics behind its picture: field names and roles at a narrow
|
||||
//! viewport, visible and clickable Cancel buttons, and history row geometry at a larger font.
|
||||
|
||||
use craft_ai_core::history::{EndRecord, HISTORY_SCHEMA, StartRecord, merge};
|
||||
use craft_ai_core::{HistoryLog, HistoryRecord, JobState, Locality, Outcome};
|
||||
use craft_ai_egui::{
|
||||
CANCEL, CLEAR, EXPORT, FormEvent, HistoryAction, HistoryViewState, ProgressAction, REFRESH,
|
||||
Range, connection_form, history_view, job_progress,
|
||||
};
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use egui_kittest::{Harness, HarnessBuilder, SnapshotOptions};
|
||||
|
||||
const T0: u64 = 1_760_000_000_000; // 2025-10-09T08:53:20Z
|
||||
const DARK_BACKGROUND: egui::Color32 = egui::Color32::from_rgb(27, 27, 27);
|
||||
|
||||
/// One fixed renderer and clock for every image. `wgpu` is kittest's headless renderer; its default
|
||||
/// setup prefers a CPU adapter so the parent can qualify the image without a physical GPU.
|
||||
fn snapshot_builder<State>(size: egui::Vec2) -> HarnessBuilder<State> {
|
||||
Harness::builder()
|
||||
.wgpu()
|
||||
.with_size(size)
|
||||
.with_pixels_per_point(1.0)
|
||||
.with_os(egui::os::OperatingSystem::Nix)
|
||||
.with_theme(egui::Theme::Dark)
|
||||
.with_step_dt(0.25)
|
||||
.with_max_steps(4)
|
||||
.with_render_cursor(false)
|
||||
.with_context_setup(|ctx| {
|
||||
ctx.set_fonts(egui::FontDefinitions::default());
|
||||
})
|
||||
}
|
||||
|
||||
fn snapshot_options() -> SnapshotOptions {
|
||||
SnapshotOptions::new()
|
||||
.output_path("tests/snapshots")
|
||||
.threshold(0.6)
|
||||
.max_failed_pixels(0)
|
||||
}
|
||||
|
||||
fn screen(size: egui::Vec2) -> egui::Rect {
|
||||
egui::Rect::from_min_size(egui::Pos2::ZERO, size)
|
||||
}
|
||||
|
||||
struct Job {
|
||||
run: &'static str,
|
||||
at: u64,
|
||||
app: &'static str,
|
||||
model: &'static str,
|
||||
outcome: Outcome,
|
||||
}
|
||||
|
||||
fn job(run: &'static str, at: u64, model: &'static str) -> Job {
|
||||
Job {
|
||||
run,
|
||||
at,
|
||||
app: "photocraft",
|
||||
model,
|
||||
outcome: Outcome::Done,
|
||||
}
|
||||
}
|
||||
|
||||
fn history_log(jobs: &[Job]) -> HistoryLog {
|
||||
let mut records = Vec::new();
|
||||
for job in jobs {
|
||||
records.push(HistoryRecord::Start(StartRecord {
|
||||
v: HISTORY_SCHEMA,
|
||||
run: job.run.into(),
|
||||
at_ms: job.at,
|
||||
app: job.app.into(),
|
||||
role_id: "fill".into(),
|
||||
role_label: "Generative Fill".into(),
|
||||
capability: "image.inpaint".into(),
|
||||
connection: "hosted".into(),
|
||||
connection_label: "Hosted".into(),
|
||||
model: job.model.into(),
|
||||
locality: Locality::Remote,
|
||||
destination: "https://api.example.com".into(),
|
||||
sent: Vec::new(),
|
||||
params: Vec::new(),
|
||||
provider_job: None,
|
||||
estimate: None,
|
||||
prompt: None,
|
||||
}));
|
||||
records.push(HistoryRecord::End(EndRecord {
|
||||
v: HISTORY_SCHEMA,
|
||||
run: job.run.into(),
|
||||
at_ms: job.at + 1000,
|
||||
outcome: job.outcome.clone(),
|
||||
usage: Default::default(),
|
||||
}));
|
||||
}
|
||||
merge(records, 0)
|
||||
}
|
||||
|
||||
// ---- history: a larger font and a narrow viewport ----
|
||||
|
||||
struct HistoryState {
|
||||
view: HistoryViewState,
|
||||
log: HistoryLog,
|
||||
now: u64,
|
||||
row_height: f32,
|
||||
spacing: f32,
|
||||
action: Option<HistoryAction>,
|
||||
}
|
||||
|
||||
fn large_font_history() -> Harness<'static, HistoryState> {
|
||||
let jobs: Vec<Job> = (0..40u64)
|
||||
.map(|i| {
|
||||
let run: &'static str = Box::leak(format!("run-{i}").into_boxed_str());
|
||||
let model: &'static str = Box::leak(format!("model-{i:02}").into_boxed_str());
|
||||
job(run, T0 + i * 60_000, model)
|
||||
})
|
||||
.collect();
|
||||
snapshot_builder(egui::vec2(480.0, 700.0)).build_ui_state(
|
||||
|ui, state: &mut HistoryState| {
|
||||
ui.painter()
|
||||
.rect_filled(ui.max_rect(), 0.0, DARK_BACKGROUND);
|
||||
ui.style_mut()
|
||||
.text_styles
|
||||
.insert(egui::TextStyle::Body, egui::FontId::proportional(26.0));
|
||||
state.row_height = ui.text_style_height(&egui::TextStyle::Body);
|
||||
state.spacing = ui.spacing().item_spacing.y;
|
||||
if let Some(action) = history_view(ui, &mut state.view, &state.log, state.now) {
|
||||
state.action = Some(action);
|
||||
}
|
||||
},
|
||||
HistoryState {
|
||||
view: HistoryViewState::default(),
|
||||
log: history_log(&jobs),
|
||||
now: T0 + 40 * 60_000,
|
||||
row_height: 0.0,
|
||||
spacing: 0.0,
|
||||
action: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn visible_rows(h: &Harness<'_, HistoryState>) -> Vec<(usize, egui::Rect)> {
|
||||
let mut rows: Vec<(usize, egui::Rect)> = h
|
||||
.query_all_by_label_contains("photocraft")
|
||||
.filter_map(|node| {
|
||||
let accessibility = node.accesskit_node();
|
||||
let label = accessibility.value().or_else(|| accessibility.label())?;
|
||||
let part = label.split(" \u{b7} ").nth(3)?;
|
||||
let model = part.split(' ').next()?;
|
||||
let index = model.strip_prefix("model-")?.parse().ok()?;
|
||||
Some((index, node.rect()))
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| a.1.top().total_cmp(&b.1.top()));
|
||||
rows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_at_a_larger_font_uses_one_text_height_per_row() {
|
||||
let mut h = large_font_history();
|
||||
h.run();
|
||||
let rows = visible_rows(&h);
|
||||
assert!(rows.len() > 2, "the large-font fixture has visible rows");
|
||||
assert_eq!(rows.first().unwrap().0, 39, "newest job first");
|
||||
let expected = h.state().row_height + h.state().spacing;
|
||||
assert!(expected > 20.0, "the fixture really uses a larger font");
|
||||
for pair in rows.windows(2) {
|
||||
assert_eq!(pair[0].0, pair[1].0 + 1, "history order is preserved");
|
||||
let delta = pair[1].1.top() - pair[0].1.top();
|
||||
assert!(
|
||||
(delta - expected).abs() < 1.5,
|
||||
"row spacing is {delta}, expected {expected}"
|
||||
);
|
||||
}
|
||||
h.snapshot_options("history_large_font_dark_480x700", &snapshot_options());
|
||||
}
|
||||
|
||||
fn narrow_history() -> Harness<'static, HistoryState> {
|
||||
let jobs = [
|
||||
job("a", T0, "flux"),
|
||||
job("b", T0 + 1000, "gpt-4o"),
|
||||
job("c", T0 + 2000, "flux"),
|
||||
];
|
||||
let log = history_log(&jobs);
|
||||
let mut view = HistoryViewState::default();
|
||||
view.range = Range::All;
|
||||
snapshot_builder(egui::vec2(240.0, 420.0)).build_ui_state(
|
||||
move |ui, state: &mut HistoryState| {
|
||||
ui.painter()
|
||||
.rect_filled(ui.max_rect(), 0.0, DARK_BACKGROUND);
|
||||
state.row_height = ui.text_style_height(&egui::TextStyle::Body);
|
||||
state.spacing = ui.spacing().item_spacing.y;
|
||||
if let Some(action) = history_view(ui, &mut state.view, &state.log, state.now) {
|
||||
state.action = Some(action);
|
||||
}
|
||||
},
|
||||
HistoryState {
|
||||
view,
|
||||
log,
|
||||
now: T0 + 5000,
|
||||
row_height: 0.0,
|
||||
spacing: 0.0,
|
||||
action: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_filters_and_actions_stay_inside_a_narrow_viewport() {
|
||||
let mut h = narrow_history();
|
||||
h.run();
|
||||
assert_eq!(
|
||||
h.query_all_by_label_contains("photocraft").count(),
|
||||
3,
|
||||
"all three synthetic jobs are shown"
|
||||
);
|
||||
let viewport = screen(egui::vec2(240.0, 420.0));
|
||||
for label in ["Filter by app", "Filter by model", "Date range"] {
|
||||
let node = h.get_by_label(label);
|
||||
assert!(
|
||||
viewport.contains_rect(node.rect()),
|
||||
"{label} is outside {viewport:?}: {:?}",
|
||||
node.rect()
|
||||
);
|
||||
}
|
||||
for label in [REFRESH, EXPORT, CLEAR] {
|
||||
let node = h.get_by_label(label);
|
||||
assert!(
|
||||
viewport.contains_rect(node.rect()),
|
||||
"{label} is outside {viewport:?}: {:?}",
|
||||
node.rect()
|
||||
);
|
||||
}
|
||||
assert!(h.unnamed_widgets().is_empty());
|
||||
h.snapshot_options("history_narrow_dark_240x420", &snapshot_options());
|
||||
h.get_by_label(REFRESH).click();
|
||||
h.run();
|
||||
assert_eq!(h.state().action, Some(HistoryAction::Refresh));
|
||||
}
|
||||
|
||||
// ---- progress: Cancel in both layout directions ----
|
||||
|
||||
struct ProgressState {
|
||||
job: JobState,
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
fn progress_harness(right_to_left: bool) -> Harness<'static, ProgressState> {
|
||||
let size = egui::vec2(320.0, 480.0);
|
||||
snapshot_builder(size).build_ui_state(
|
||||
move |ui, state: &mut ProgressState| {
|
||||
ui.painter()
|
||||
.rect_filled(ui.max_rect(), 0.0, DARK_BACKGROUND);
|
||||
let draw = |ui: &mut egui::Ui, state: &mut ProgressState| {
|
||||
if job_progress(ui, &state.job, None) == Some(ProgressAction::Cancel) {
|
||||
state.cancelled = true;
|
||||
}
|
||||
};
|
||||
if right_to_left {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
|
||||
draw(ui, state);
|
||||
});
|
||||
} else {
|
||||
draw(ui, state);
|
||||
}
|
||||
},
|
||||
ProgressState {
|
||||
job: JobState::Running { percent: Some(40) },
|
||||
cancelled: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn cancel_bounds_are_usable(h: &Harness<'_, ProgressState>, right_to_left: bool) {
|
||||
let viewport = screen(egui::vec2(320.0, 480.0));
|
||||
let bar = h.get_by_label_contains("Working, 40%");
|
||||
let cancel = h.get_by_label(CANCEL);
|
||||
assert!(
|
||||
bar.rect().top() < 40.0 && cancel.rect().top() < 40.0,
|
||||
"the progress row must remain at the top in either direction: bar {:?}, cancel {:?}",
|
||||
bar.rect(),
|
||||
cancel.rect()
|
||||
);
|
||||
assert!(viewport.contains_rect(bar.rect()));
|
||||
assert!(viewport.contains_rect(cancel.rect()));
|
||||
assert!(
|
||||
!bar.rect().intersects(cancel.rect()),
|
||||
"bar {:?} overlaps Cancel {:?}",
|
||||
bar.rect(),
|
||||
cancel.rect()
|
||||
);
|
||||
if right_to_left {
|
||||
assert!(
|
||||
cancel.rect().right() <= bar.rect().left() + 0.5,
|
||||
"right-to-left order puts Cancel before the bar: bar {:?}, cancel {:?}",
|
||||
bar.rect(),
|
||||
cancel.rect()
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
bar.rect().right() <= cancel.rect().left() + 0.5,
|
||||
"left-to-right order puts Cancel after the bar: bar {:?}, cancel {:?}",
|
||||
bar.rect(),
|
||||
cancel.rect()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_cancel_is_visible_and_clickable_left_to_right() {
|
||||
let mut h = progress_harness(false);
|
||||
h.run();
|
||||
cancel_bounds_are_usable(&h, false);
|
||||
h.snapshot_options("progress_cancel_ltr_dark_320x480", &snapshot_options());
|
||||
h.get_by_label(CANCEL).click();
|
||||
h.run();
|
||||
assert!(h.state().cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_cancel_is_visible_and_clickable_right_to_left() {
|
||||
let mut h = progress_harness(true);
|
||||
h.run();
|
||||
cancel_bounds_are_usable(&h, true);
|
||||
h.snapshot_options("progress_cancel_rtl_dark_320x480", &snapshot_options());
|
||||
h.get_by_label(CANCEL).click();
|
||||
h.run();
|
||||
assert!(h.state().cancelled);
|
||||
}
|
||||
|
||||
// ---- connection form: names, roles and bounds at a narrow viewport ----
|
||||
|
||||
struct FormState {
|
||||
form: craft_ai_egui::ConnectionFormState,
|
||||
event: Option<FormEvent>,
|
||||
}
|
||||
|
||||
fn connection_harness() -> Harness<'static, FormState> {
|
||||
snapshot_builder(egui::vec2(240.0, 560.0)).build_ui_state(
|
||||
|ui, state: &mut FormState| {
|
||||
ui.painter()
|
||||
.rect_filled(ui.max_rect(), 0.0, DARK_BACKGROUND);
|
||||
if let Some(event) = connection_form(ui, &mut state.form, false) {
|
||||
state.event = Some(event);
|
||||
}
|
||||
},
|
||||
FormState {
|
||||
form: craft_ai_egui::ConnectionFormState::new(),
|
||||
event: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_fields_keep_their_names_roles_and_bounds_when_narrow() {
|
||||
let mut h = connection_harness();
|
||||
h.run();
|
||||
let viewport = screen(egui::vec2(240.0, 560.0));
|
||||
let fields: Vec<(Role, Option<String>, egui::Rect)> = h
|
||||
.get_all_by_role(Role::TextInput)
|
||||
.map(|node| {
|
||||
let accessibility = node.accesskit_node();
|
||||
(accessibility.role(), accessibility.label(), node.rect())
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
fields
|
||||
.iter()
|
||||
.map(|(role, label, _)| (*role, label.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
(Role::TextInput, Some("Name".to_owned())),
|
||||
(Role::TextInput, Some("Address".to_owned())),
|
||||
]
|
||||
);
|
||||
for (_, label, rect) in &fields {
|
||||
assert!(
|
||||
viewport.contains_rect(*rect),
|
||||
"{label:?} is outside {viewport:?}: {rect:?}"
|
||||
);
|
||||
}
|
||||
let key = h.get_by_role(Role::PasswordInput);
|
||||
assert_eq!(key.accesskit_node().label().as_deref(), Some("Key"));
|
||||
assert!(viewport.contains_rect(key.rect()));
|
||||
assert!(h.unnamed_widgets().is_empty());
|
||||
for label in ["Uses a key", "Save", "Test", "Cancel"] {
|
||||
let node = h.get_by_label(label);
|
||||
assert!(
|
||||
viewport.contains_rect(node.rect()),
|
||||
"{label} is outside {viewport:?}: {:?}",
|
||||
node.rect()
|
||||
);
|
||||
}
|
||||
|
||||
h.snapshot_options("connection_fields_narrow_dark_240x560", &snapshot_options());
|
||||
h.get_by_label("Save").click();
|
||||
h.run();
|
||||
assert_eq!(h.state().event, Some(FormEvent::Save));
|
||||
}
|
||||
@@ -7,12 +7,13 @@ use craft_ai_core::{
|
||||
InputRole, JobState, Locality, ModelId, PayloadKind,
|
||||
};
|
||||
use craft_ai_egui::{
|
||||
ADD_CONNECTION, ALLOW, CANCEL, ConsentChoice, DENY, OPEN_LAUNCHER, ProgressAction,
|
||||
StatusAction, StatusView, USE_DEFAULT, ai_status, consent_prompt, job_progress,
|
||||
ADD_CONNECTION, ALLOW, CANCEL, ConsentChoice, ConsentPromptState, DENY, OPEN_LAUNCHER,
|
||||
ProgressAction, StatusAction, StatusView, TimedConsentChoice, USE_DEFAULT, ai_status,
|
||||
consent_prompt, consent_prompt_with_terms, consent_term_label, job_progress,
|
||||
};
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
|
||||
fn disclosure(locality: Locality) -> Disclosure {
|
||||
Disclosure {
|
||||
@@ -130,6 +131,24 @@ fn status_harness(s: StatusState) -> Harness<'static, StatusState> {
|
||||
)
|
||||
}
|
||||
|
||||
fn status_harness_sized(size: egui::Vec2, s: StatusState) -> Harness<'static, StatusState> {
|
||||
Harness::builder().with_size(size).build_ui_state(
|
||||
|ui, st: &mut StatusState| {
|
||||
let view = StatusView {
|
||||
label: "Generative Fill",
|
||||
status: &st.status,
|
||||
choices: &st.choices,
|
||||
has_override: st.has_override,
|
||||
can_add_here: st.can_add_here,
|
||||
};
|
||||
if let Some(a) = ai_status(ui, &view) {
|
||||
st.last = Some(a);
|
||||
}
|
||||
},
|
||||
s,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_feature_explains_itself_and_offers_the_launcher() {
|
||||
let mut h = status_harness(StatusState {
|
||||
@@ -293,6 +312,47 @@ fn progress_harness(job: JobState, cost: Option<CostEstimate>) -> Harness<'stati
|
||||
)
|
||||
}
|
||||
|
||||
fn progress_harness_sized(
|
||||
size: egui::Vec2,
|
||||
job: JobState,
|
||||
cost: Option<CostEstimate>,
|
||||
) -> Harness<'static, ProgressState> {
|
||||
Harness::builder().with_size(size).build_ui_state(
|
||||
|ui, st: &mut ProgressState| {
|
||||
if job_progress(ui, &st.job, st.cost.as_ref()) == Some(ProgressAction::Cancel) {
|
||||
st.cancelled = true;
|
||||
}
|
||||
},
|
||||
ProgressState {
|
||||
job,
|
||||
cost,
|
||||
cancelled: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The same widget under a right-to-left host layout, at a narrow width.
|
||||
fn progress_harness_rtl_sized(
|
||||
size: egui::Vec2,
|
||||
job: JobState,
|
||||
cost: Option<CostEstimate>,
|
||||
) -> Harness<'static, ProgressState> {
|
||||
Harness::builder().with_size(size).build_ui_state(
|
||||
|ui, st: &mut ProgressState| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
|
||||
if job_progress(ui, &st.job, st.cost.as_ref()) == Some(ProgressAction::Cancel) {
|
||||
st.cancelled = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
ProgressState {
|
||||
job,
|
||||
cost,
|
||||
cancelled: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_job_shows_percent_cost_and_can_be_cancelled() {
|
||||
let cost = CostEstimate {
|
||||
@@ -337,3 +397,242 @@ fn finished_jobs_say_how_they_ended_and_cannot_be_cancelled() {
|
||||
assert!(h.query_by_label(CANCEL).is_none(), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_consent_radios_expose_their_selected_state_and_duration() {
|
||||
struct State {
|
||||
prompt: ConsentPromptState,
|
||||
choice: Option<TimedConsentChoice>,
|
||||
}
|
||||
let d = disclosure(Locality::Remote);
|
||||
let mut h = Harness::new_ui_state(
|
||||
move |ui, state: &mut State| {
|
||||
if let Some(choice) = consent_prompt_with_terms(ui, &d, "PhotoCraft", &mut state.prompt)
|
||||
{
|
||||
state.choice = Some(choice);
|
||||
}
|
||||
},
|
||||
State {
|
||||
prompt: ConsentPromptState::default(),
|
||||
choice: None,
|
||||
},
|
||||
);
|
||||
h.run();
|
||||
let this_time = h.get_by_label(consent_term_label(craft_ai_core::ConsentTerm::ThisTime));
|
||||
assert_eq!(this_time.accesskit_node().role(), Role::RadioButton);
|
||||
assert_eq!(
|
||||
this_time.accesskit_node().toggled(),
|
||||
Some(egui::accesskit::Toggled::True)
|
||||
);
|
||||
let one_hour = h.get_by_label(consent_term_label(craft_ai_core::ConsentTerm::OneHour));
|
||||
assert_eq!(
|
||||
one_hour.accesskit_node().toggled(),
|
||||
Some(egui::accesskit::Toggled::False)
|
||||
);
|
||||
one_hour.click();
|
||||
h.run();
|
||||
assert_eq!(
|
||||
h.get_by_label(consent_term_label(craft_ai_core::ConsentTerm::OneHour))
|
||||
.accesskit_node()
|
||||
.toggled(),
|
||||
Some(egui::accesskit::Toggled::True)
|
||||
);
|
||||
h.get_by_label(ALLOW).click();
|
||||
h.run();
|
||||
assert_eq!(
|
||||
h.state().choice,
|
||||
Some(TimedConsentChoice::Allow(
|
||||
craft_ai_core::ConsentTerm::OneHour
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_model_picker_has_a_field_name_not_just_its_value() {
|
||||
let mut h = status_harness(StatusState {
|
||||
status: ready("local", "llama3", "Llama 3"),
|
||||
choices: vec![
|
||||
choice("local", "llama3", "Llama 3", Locality::Local),
|
||||
choice("cloud", "gpt-4o", "GPT-4o", Locality::Remote),
|
||||
],
|
||||
has_override: false,
|
||||
can_add_here: false,
|
||||
last: None,
|
||||
});
|
||||
h.run();
|
||||
let combo = h.get_by_role(Role::ComboBox);
|
||||
assert_eq!(
|
||||
combo.accesskit_node().label().as_deref(),
|
||||
Some("Model for Generative Fill")
|
||||
);
|
||||
assert!(h.unnamed_widgets().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_commands_stay_inside_a_narrow_header() {
|
||||
let mut h = status_harness_sized(
|
||||
egui::vec2(180.0, 360.0),
|
||||
StatusState {
|
||||
status: RoleStatus::Unavailable {
|
||||
reason: "no connection".into(),
|
||||
},
|
||||
choices: vec![],
|
||||
has_override: false,
|
||||
can_add_here: true,
|
||||
last: None,
|
||||
},
|
||||
);
|
||||
h.run();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(180.0, 360.0));
|
||||
for label in [OPEN_LAUNCHER, ADD_CONNECTION] {
|
||||
let node = h.get_by_label(label);
|
||||
assert!(
|
||||
screen.contains_rect(node.rect()),
|
||||
"{label} is outside {screen:?}: {:?}",
|
||||
node.rect()
|
||||
);
|
||||
}
|
||||
h.get_by_label(ADD_CONNECTION).click();
|
||||
h.run();
|
||||
assert_eq!(h.state().last, Some(StatusAction::AddConnection));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_stays_visible_and_does_not_overlap_progress_at_320() {
|
||||
let mut h = progress_harness_sized(
|
||||
egui::vec2(320.0, 480.0),
|
||||
JobState::Running { percent: Some(40) },
|
||||
None,
|
||||
);
|
||||
h.run();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 480.0));
|
||||
let bar = h.get_by_label_contains("Working, 40%");
|
||||
let cancel = h.get_by_label(CANCEL);
|
||||
assert!(screen.contains_rect(bar.rect()));
|
||||
assert!(screen.contains_rect(cancel.rect()));
|
||||
assert!(
|
||||
!bar.rect().intersects(cancel.rect()),
|
||||
"bar {:?} overlaps Cancel {:?}",
|
||||
bar.rect(),
|
||||
cancel.rect()
|
||||
);
|
||||
cancel.click();
|
||||
h.run();
|
||||
assert!(h.state().cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_cancel_wraps_below_the_bar_and_stays_clickable() {
|
||||
let mut h = progress_harness_sized(
|
||||
egui::vec2(120.0, 480.0),
|
||||
JobState::Running { percent: Some(40) },
|
||||
None,
|
||||
);
|
||||
h.run();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(120.0, 480.0));
|
||||
let bar = h.get_by_label_contains("Working, 40%");
|
||||
let cancel = h.get_by_label(CANCEL);
|
||||
assert!(screen.contains_rect(cancel.rect()), "Cancel is clipped");
|
||||
assert!(
|
||||
cancel.rect().top() >= bar.rect().bottom(),
|
||||
"Cancel should wrap below the bar: bar {:?}, cancel {:?}",
|
||||
bar.rect(),
|
||||
cancel.rect()
|
||||
);
|
||||
cancel.click();
|
||||
h.run();
|
||||
assert!(h.state().cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_spinner_job_also_keeps_cancel_reachable() {
|
||||
let mut h = progress_harness_sized(
|
||||
egui::vec2(120.0, 480.0),
|
||||
JobState::Running { percent: None },
|
||||
None,
|
||||
);
|
||||
h.step();
|
||||
h.step();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(120.0, 480.0));
|
||||
let cancel = h.get_by_label(CANCEL);
|
||||
assert!(screen.contains_rect(cancel.rect()));
|
||||
cancel.click();
|
||||
// The spinner keeps requesting repaints, so one step is enough to see the click.
|
||||
h.step();
|
||||
assert!(h.state().cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_narrow_progress_keeps_cancel_visible_and_apart_from_the_bar() {
|
||||
let mut h = progress_harness_rtl_sized(
|
||||
egui::vec2(320.0, 480.0),
|
||||
JobState::Running { percent: Some(40) },
|
||||
None,
|
||||
);
|
||||
h.run();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 480.0));
|
||||
let bar = h.get_by_label_contains("Working, 40%");
|
||||
let cancel = h.get_by_label(CANCEL);
|
||||
assert!(screen.contains_rect(bar.rect()));
|
||||
assert!(screen.contains_rect(cancel.rect()));
|
||||
assert!(
|
||||
!bar.rect().intersects(cancel.rect()),
|
||||
"bar {:?} overlaps Cancel {:?}",
|
||||
bar.rect(),
|
||||
cancel.rect()
|
||||
);
|
||||
assert!(
|
||||
cancel.rect().right() <= bar.rect().left() + 0.5,
|
||||
"right-to-left order puts Cancel before the bar: bar {:?}, cancel {:?}",
|
||||
bar.rect(),
|
||||
cancel.rect()
|
||||
);
|
||||
cancel.click();
|
||||
h.run();
|
||||
assert!(h.state().cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_narrow_spinner_job_keeps_cancel_visible_and_clickable() {
|
||||
let mut h = progress_harness_rtl_sized(
|
||||
egui::vec2(320.0, 480.0),
|
||||
JobState::Running { percent: None },
|
||||
None,
|
||||
);
|
||||
// The spinner keeps asking for repaints, so step instead of running to rest.
|
||||
h.step();
|
||||
h.step();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 480.0));
|
||||
let label = h.get_by_label("Working");
|
||||
let cancel = h.get_by_label(CANCEL);
|
||||
assert!(screen.contains_rect(label.rect()));
|
||||
assert!(screen.contains_rect(cancel.rect()));
|
||||
assert!(
|
||||
!label.rect().intersects(cancel.rect()),
|
||||
"label {:?} overlaps Cancel {:?}",
|
||||
label.rect(),
|
||||
cancel.rect()
|
||||
);
|
||||
assert!(
|
||||
cancel.rect().right() <= label.rect().left() + 0.5,
|
||||
"right-to-left order puts Cancel before the label: label {:?}, cancel {:?}",
|
||||
label.rect(),
|
||||
cancel.rect()
|
||||
);
|
||||
cancel.click();
|
||||
h.step();
|
||||
assert!(h.state().cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fixed_fixture_glyphs_are_in_the_test_fonts() {
|
||||
let mut h = Harness::new_ui_state(
|
||||
|ui, _| {
|
||||
ui.label("\u{2022} bullet \u{2026} ellipsis \u{2014} dash \u{00b7} middle dot");
|
||||
},
|
||||
(),
|
||||
);
|
||||
// The harness panics on a missing glyph, which is the assertion here.
|
||||
h.run();
|
||||
assert!(h.unnamed_widgets().is_empty());
|
||||
}
|
||||
|
||||
@@ -246,3 +246,68 @@ impl Provider for EngineProvider {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use craft_ai_core::JobState;
|
||||
|
||||
use super::*;
|
||||
use crate::testing::FakeRunner;
|
||||
|
||||
fn entry(fake: FakeRunner, dir: &tempfile::TempDir) -> Arc<Entry> {
|
||||
let weights = dir.path().join("m.safetensors");
|
||||
std::fs::write(&weights, b"w").unwrap();
|
||||
let provider = EngineProvider::new(
|
||||
"candle",
|
||||
[(Arc::new(fake) as Arc<dyn ModelRunner>, weights)],
|
||||
);
|
||||
provider.entries[0].clone()
|
||||
}
|
||||
|
||||
/// The worker thread's first act is `Entry::run`. A job cancelled before that call must not
|
||||
/// load the weights or enter the runner, even when this entry is the first to touch them.
|
||||
/// The same entry then loads and runs an uncancelled job, so the two zeroes come from the
|
||||
/// cancel guard rather than from a fixture that could never have loaded or run anything.
|
||||
#[test]
|
||||
fn a_job_cancelled_before_its_turn_never_loads_or_runs_the_model() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut fake = FakeRunner::new("candle", "sam3", "image.segment");
|
||||
fake.step_pause = Duration::ZERO;
|
||||
let (loads, runs) = (fake.loads.clone(), fake.runs.clone());
|
||||
let request = fake.good_request().unwrap();
|
||||
let entry = entry(fake, &dir);
|
||||
|
||||
let cancelled = Job::new();
|
||||
cancelled.cancel();
|
||||
let result = entry.run(&request, &cancelled);
|
||||
assert_eq!(
|
||||
result.as_ref().err().map(|e| e.kind),
|
||||
Some(ErrorKind::Cancelled)
|
||||
);
|
||||
cancelled.finish(result);
|
||||
assert_eq!(cancelled.snapshot().state, JobState::Cancelled);
|
||||
assert_eq!(
|
||||
loads.load(Ordering::SeqCst),
|
||||
0,
|
||||
"a cancelled job must not load the model"
|
||||
);
|
||||
assert_eq!(
|
||||
runs.load(Ordering::SeqCst),
|
||||
0,
|
||||
"a cancelled job must not run the model"
|
||||
);
|
||||
|
||||
// Positive control through the same production path. If the `job.begin()` guard were
|
||||
// removed, the cancelled job above would load (and, without the cancel check, run), so
|
||||
// these counters would no longer be 0.
|
||||
let control = Job::new();
|
||||
let result = entry.run(&request, &control);
|
||||
assert!(matches!(&result, Ok(outputs) if outputs.len() == 1));
|
||||
control.finish(result);
|
||||
assert_eq!(control.snapshot().state, JobState::Succeeded);
|
||||
assert_eq!(loads.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(runs.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,36 +263,6 @@ fn a_job_waiting_behind_another_says_queued_and_cancelled_it_never_runs() {
|
||||
assert_eq!(loads.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_cancelled_before_its_model_is_loaded_never_loads_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut r = fake();
|
||||
r.steps = 1_000;
|
||||
let (loads, runs) = (r.loads.clone(), r.runs.clone());
|
||||
let req = r.good_request().unwrap();
|
||||
// Two models on one provider: the second is loaded only if a job for it gets to run.
|
||||
let other = FakeRunner::new("candle", "other", "image.segment");
|
||||
let other_loads = other.loads.clone();
|
||||
let other_req = other.good_request().unwrap();
|
||||
let p = EngineProvider::new(
|
||||
"candle",
|
||||
[
|
||||
(Arc::new(r) as Arc<dyn ModelRunner>, weights(&dir)),
|
||||
(Arc::new(other) as Arc<dyn ModelRunner>, weights(&dir)),
|
||||
],
|
||||
);
|
||||
let busy = p.submit(&model(), &req).unwrap();
|
||||
until("the busy job runs", || runs.load(Ordering::SeqCst) == 1);
|
||||
let other_job = p
|
||||
.submit(&ModelId::new("other").unwrap(), &other_req)
|
||||
.unwrap();
|
||||
p.cancel(&other_job).unwrap();
|
||||
p.cancel(&busy).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
assert_eq!(other_loads.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(loads.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_burst_of_requests_is_refused_in_plain_words_and_room_returns_when_they_end() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -52,3 +52,8 @@ wildcards = "allow"
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
# The workspace pins the reviewed egui-family and winit forks in Cargo.toml.
|
||||
allow-git = [
|
||||
"https://github.com/storytold/egui.git",
|
||||
"https://github.com/storytold/winit.git",
|
||||
]
|
||||
|
||||
+14
-3
@@ -14,10 +14,10 @@ data, consent and jobs. It does not own any app's interface. Each app decides ho
|
||||
| HTTP | `ai-http` | The one TLS setting every adapter and downloader uses (the platform's certificate check), and a test that fails if any crate builds an agent without it. |
|
||||
| Engines | `ai-engine`, `ai-engine-candle`, `ai-engine-rten`, `ai-engine-burn`, `ai-burn-setup` | Models run on this computer: the runner contract, registry and shared provider (`ai-engine`), and one crate per engine that registers its models (`ai-engine-candle`, `ai-engine-rten` and `ai-engine-burn`, behind `ai-client`'s `candle`, `rten` and `burn` features). `ai-burn-setup` prepares the saved weights Burn reads, once on this computer. A new model or engine never touches an app. |
|
||||
| Local runtime | `ai-comfy-env`, `ai-media` | Finding an existing ComfyUI, model sources and checksummed downloads, node packs, and durable media transfer. |
|
||||
| Widgets | `ai-egui` | Theme-neutral egui widgets (status, consent, progress, connection panel, history, model picker) for apps that have no chrome of their own. |
|
||||
| Widgets | `ai-egui` | Theme-neutral egui 0.37 widgets (status, consent, progress, connection panel, history, model picker) for apps that have no chrome of their own. See the consumer recipe below. |
|
||||
|
||||
Crates keep the flat `crates/` folder on purpose. PhotoCraft and the launcher reach into it by
|
||||
path today, so moving a crate breaks their builds.
|
||||
Crates keep the flat `crates/` folder on purpose. The exact planning checkouts reproduced no app
|
||||
dependency on `craft-ai-egui`; recheck any current path consumer before moving a crate.
|
||||
|
||||
`docs/design/` holds design notes. `docs/STATUS.md` says what is tested and what is open. The deliberate-bug
|
||||
scripts are in `tools/mutation/`; everything a script saves goes through `tools/mutation/scrub.py`.
|
||||
@@ -45,6 +45,17 @@ list. If a change here needs to name an app's behaviour, it belongs in the app.
|
||||
4. A person's choice made for the whole suite is `ToolDesk::choose` (the default every app reads).
|
||||
`AiClient::bind` is that one app's own override.
|
||||
|
||||
## Using the shared egui widgets
|
||||
|
||||
`craft-ai-egui` is optional and has no app adopter in this repository. An egui app repeats the
|
||||
complete `[patch.crates-io]` recipe in its own root; this repository's patch is not inherited.
|
||||
The reviewed graph is `storytold/egui` at `6d518fc799681571963842f95b55a6b68431b15a` and
|
||||
`storytold/winit` at `a517b3846ee8457d84ee4975110bfda6797c44dc`. Keep the library at
|
||||
`default-features = false` for fonts and let the app choose its font provider (or eframe's
|
||||
bundled fonts). Do not combine this workspace's `craft-ai-core` with the separate copy in
|
||||
`craft-libs`; the [consumer smoke](../consumer-smoke/README.md) shows the one-identity check and
|
||||
passes its locked check and one draw test. That headless result is not native GUI acceptance.
|
||||
|
||||
## Branches
|
||||
|
||||
- `main` is a state that builds and passes its tests. Apps pin tags on it.
|
||||
|
||||
@@ -20,6 +20,23 @@ A registered tool is a **declaration**, not installed code. A compatible binding
|
||||
|
||||
Concrete API examples and regressions: `crates/ai-client/tests/custom_tools.rs`, `crates/ai-core/tests/custom_tools.rs` and `crates/ai-native/tests/shared_store.rs`. The shared store is `craft_ai_native::shared::SharedStore`; it uses locked atomic updates for registration, binding and removal. Old version-1 JSON files without `custom_tools` still load with an empty list. Secret values belong in the secret store, never in the shared JSON or a tool declaration.
|
||||
|
||||
## Optional shared egui widgets
|
||||
|
||||
`craft-ai-egui` is a set of theme-neutral widgets, not a required app dependency. No app dependency
|
||||
is reproduced in this repository's checkouts. An egui app may adopt it, but must pin the same
|
||||
coherent graph and repeat the root patch recipe:
|
||||
|
||||
- `storytold/egui` at `6d518fc799681571963842f95b55a6b68431b15a` for the egui family;
|
||||
- `storytold/winit` at `a517b3846ee8457d84ee4975110bfda6797c44dc`.
|
||||
|
||||
The library keeps `egui` at `default-features = false`. The app supplies fonts, or enables eframe's
|
||||
bundled `default_fonts`; the shared widgets do not turn on system-font discovery. kittest `wgpu` and
|
||||
`snapshot` are test-only features. Keep one `craft-ai-core` identity: this workspace's local copy
|
||||
and `craft-libs/crates/ai-core` are not interchangeable until the [CORE-SYNC](CORE-SYNC.md) work
|
||||
lands. The [consumer smoke](../consumer-smoke/README.md) passed its locked check and one draw test with a
|
||||
single `craft-ai-core` identity and the pinned egui family; its rendererless root does not exercise
|
||||
winit. It is not an app adoption or a native GUI acceptance result.
|
||||
|
||||
## Deliberate limits and next gate
|
||||
|
||||
The current surface does **not** ship a dynamic adapter registry for arbitrary user code, a general tool executor, app-specific menus, automatic model installation or a complete provider route. Existing native/hosted adapters have separate capability and real-service gates. The executable adoption example the maintainers asked for exists: `crates/ai-client/tests/third_party_adoption.rs`, walked through in [ADOPT-A-TOOL.md](ADOPT-A-TOOL.md). It proves a declared tool, a bound connection and a registered adapter running one request across two independent app clients, with consent per app, cancellation, an app that lacks the adapter (refused at `plan`, no key read), and a store file from before custom tools existed. Not covered: a real store migration (the schema is still version 1, so there is nothing to migrate yet), a real keychain, and a real hosted service. The apps themselves remain outside this shared-platform task.
|
||||
|
||||
+2
-1
@@ -18,10 +18,11 @@ No real hosted service, download host or repository was contacted by these tests
|
||||
| Generic ComfyUI workflows | `craft-ai-comfyui` | Maps a prompt field; `image.inpaint` needs mapped `LoadImage` and `LoadImageMask` fields. A workflow is offered as a model only after `/object_info` confirms its nodes and checkpoint names; submission repeats the check. Output stays inline (32 MiB) unless the app gives a directory (1 GiB, local `file:` handle, app owns cleanup). Targeted cancel is off by default because an older server may treat it as a global interrupt. Imported image.segment workflows map a description and source to a checked, source-sized grayscale mask plus the shared result. Empty masks mean no match; source uploads are remembered for selection jobs. This is not calibrated absence detection: the example Florence graph can find a box for an absent subject. No input file handles or general parameter mapping yet. |
|
||||
| Built-in engines | `craft-ai-engine`, `craft-ai-engine-candle`, `craft-ai-engine-rten`, `craft-ai-engine-burn` | Contract, registry and provider are tested with a fake runner and the shared conformance suite. SAM 3 on Candle (point and text prompts, processor, or Metal on macOS) ran on a real photograph and passes the same suite on Meta's checkpoint; the live tests skip without `CRAFT_SAM3_WEIGHTS`. It reads the Hugging Face checkpoint layout with tokenizer files, not the ComfyUI file. Pictures go in as PNG or JPEG (a JPEG's EXIF turn is applied). No Nvidia (CUDA) build, and the launcher does not download the checkpoint yet. SAM 2.1 on rten (processor only) reads the publisher's two `.onnx` files, pinned by size and SHA-256, with click and box prompts; a description is refused in plain words. Pictures are bounded by side and pixel count. With the real files in release on one Windows machine: a mask at the picture's size, and a hover through the ordinary job path with a median of about 35 ms after a first look of about 0.7 s (one picture, not a benchmark). SAM 2.1 is the first recipe for Select subject, so the plain built-in offer is SAM 2.1 on rten. No macOS or Linux run, and no app has run it through the shared binding yet. SAM 2.1 on Burn (`craft-ai-engine-burn`, cargo feature `burn`, optional, after rten in the recipe) reads weights that `craft-ai-burn-setup` prepares once on the computer; on one Windows machine with a graphics card its mask matched rten's (best-mask IoU 1.0, one click and one box, one picture, not a benchmark). Given the downloaded `encoder.onnx`, the runner prepares the saved copy itself on first load (in `burn-prepared/` beside the model folder; no progress and no Cancel during that load). No app has run it through the shared binding yet, and there has been no live run on macOS or Linux. See `docs/BURN-ENGINE.md`. |
|
||||
| First-run setup flow | `craft-ai-client`, `craft-ai-egui`, `craft-ai-features` | One start button on `SetupSession` (checks complete files on disk, otherwise downloads; `blocker` says why it does nothing), a licence tick only where a file's licence needs one, `FolderDesk::ensure_default` for the suggested folder, `ToolDesk::adopt` for checked files only, a try-it sample that clicks for built-in selection models and fails a selection that chose nothing, status and picker that agree when a model's file has gone, and engine advice that does not call a measured-quick built-in engine (rten, one machine) slow. Scripted tests and a fake provider only: no real download, no model run through it, no app has mounted it, no macOS or Linux run. |
|
||||
| Shared egui widgets | `craft-ai-egui` | Source and tests migrated to egui 0.37 with the reviewed storytold egui family at `6d518fc799681571963842f95b55a6b68431b15a` and winit at `a517b3846ee8457d84ee4975110bfda6797c44dc`. The final headless candidate passed 108 tests, strict all-target clippy and `cargo fmt --check` on Linux. Five deterministic kittest `wgpu`/snapshot goldens are committed; a pure-background RGB(27,27,27) to RGB(30,30,30) change failed the expected snapshot with 75,486 changed pixels, and the two compiled connection-panel lifecycle mutants were caught before the source and golden hashes were restored and the 108 tests passed again. A locked consumer-smoke check and its one draw test passed with one `craft-ai-core` identity and the pinned egui family; that rendererless root does not use the winit patch. No app imports the widgets. Native X11/Wayland/Windows behavior, IME/clipboard/focus, physical GPU qualification and performance measurements remain open. |
|
||||
| Declarative adapter | `craft-ai-declarative` | Request/response and queue flows from a data file; prompt-only inputs. No launcher screen to add a spec yet. |
|
||||
| Custom connections, model sources, node packs | `craft-ai-core`, `craft-ai-comfy-env` | See `docs/custom-connections-and-sources.md`. Own-host tokens only, no key sent across a redirect, pickle formats need consent, unverified checksums are flagged. |
|
||||
| Model folders | `craft-ai-core`, `craft-ai-comfy-env`, `craft-ai-client` | A person's own folders of model files, kept as a map (which sub-folders hold which kind) in the shared store, never moved, renamed or deleted. Scanned read only with limits (depth, files, entries, header size), one default for downloads, folders linked from ComfyUI are read only, and the map can be written as a ComfyUI extra-paths file. Not wired yet: downloads and the installed-files scan still use ComfyUI's own folders, and no launcher screen is merged. |
|
||||
| Consent, history, shared store, key handling | `craft-ai-core`, `craft-ai-native`, `craft-ai-client` | Consent is scoped to app, connection, destination and capability, per kind of data, and may expire. `craft-ai-egui`'s prompt does not offer the consent terms yet. |
|
||||
| Consent, history, shared store, key handling | `craft-ai-core`, `craft-ai-native`, `craft-ai-client` | Consent is scoped to app, connection, destination and capability, per kind of data, and may expire. `craft-ai-egui` offers the original prompt and a timed prompt through `consent_prompt_with_terms`; no app has mounted it yet. |
|
||||
|
||||
## Run for real
|
||||
|
||||
|
||||
@@ -338,6 +338,15 @@ picker, consent prompt, progress, connection panel and history widgets. Hosts wi
|
||||
look should draw their own views over the same data. The data and the rules are shared. The
|
||||
look is yours.
|
||||
|
||||
An app adopting the widgets repeats the complete `[patch.crates-io]` recipe in its own root:
|
||||
`storytold/egui` at `6d518fc799681571963842f95b55a6b68431b15a` and `storytold/winit` at
|
||||
`a517b3846ee8457d84ee4975110bfda6797c44dc`. Patches do not propagate from this repository. The
|
||||
library does not force bundled fonts; enable eframe's `default_fonts` or supply your own provider.
|
||||
Use only this workspace's `craft-ai-core` package, not the separate `craft-libs` copy. The small
|
||||
[consumer smoke](../../consumer-smoke/README.md) workspace in this checkout checks that recipe and
|
||||
type identity; its locked check and one draw test pass headlessly. Native focus, IME, clipboard and
|
||||
rendering still need your own platform test.
|
||||
|
||||
## 13. Test your integration with no network
|
||||
|
||||
Build a world with no network: a temporary store, an in-memory key store, the launcher's choice
|
||||
|
||||
@@ -0,0 +1,774 @@
|
||||
# egui 0.37 migration plan for craft-ai-integrations
|
||||
|
||||
Current publication candidate (2026-10-25): implementation and qualification changes are committed at `087f5a0` on the migration branch; the uncommitted-source descriptions below are historical. The current panel source, snapshot-test source and all five golden image hashes match the retained passing mutation-qualification packet. The changed widget package passed 108 tests, strict all-target lint and formatting; the separate consumer smoke passed a locked check and one draw test. Full remote workspace/platform CI, native host/IME/clipboard and paired performance acceptance remain open. Publication requires explicit approval; human review and human merge are required.
|
||||
|
||||
## Historical implementation and qualification record
|
||||
|
||||
Status: implementation and headless qualification are complete for the current follow-up;
|
||||
**not acceptance-complete**. The source audit at
|
||||
`1a02afd6bed425bcd516d8de2f3dc3b29614b23d` is retained below as historical evidence. The current
|
||||
checkout is `7c80bbc52cb5a84cebeb67529a18d670a15c511b` plus local AI-5 follow-up changes (the
|
||||
deterministic snapshot suite, the separate consumer-smoke root, the panel mutation group and these
|
||||
docs). The follow-up passed 108 tests, strict all-target clippy and fmt on Linux; its five candidate
|
||||
snapshots, lifecycle mutants and locked consumer smoke are checked, and an independent test-packet
|
||||
review passed. Native GUI/IME, physical-GPU and performance qualification remain open. No push, pull
|
||||
request, comment, release, merge or other publication is authorised; each future publication and the
|
||||
final merge still need fresh explicit human approval after the exact content and validation are shown.
|
||||
|
||||
The repository is `storytold/craft-ai-integrations`. Its only egui consumer is `crates/ai-egui` (package `craft-ai-egui`).
|
||||
This plan covers that crate, its examples, tests, workspace dependency graph, and the contracts it must keep for any app that
|
||||
may adopt it.
|
||||
|
||||
### Current implemented state (2026-10-24)
|
||||
|
||||
| Item | Actual state |
|
||||
| --- | --- |
|
||||
| Source | `7c80bbc52cb5a84cebeb67529a18d670a15c511b`, plus the inherited pin-only root `Cargo.toml`/`Cargo.lock` updates and the local follow-up files listed in the change review. |
|
||||
| Production egui family | Reviewed storytold `storytold/egui` at `6d518fc799681571963842f95b55a6b68431b15a`; one family source in the root lock. |
|
||||
| Production winit | Reviewed storytold `storytold/winit` at `a517b3846ee8457d84ee4975110bfda6797c44dc`. |
|
||||
| Root lock | `Cargo.lock` SHA-256 `7505336b3831762c90b73610723161c163852edfee43daea350630e93dd4d799`; retained unchanged by this documentation, snapshot and smoke-root work. |
|
||||
| Consumer recipe | `consumer-smoke/` is a separate workspace that repeats the complete `[patch.crates-io]` recipe, path-depends on this repository's `ai-egui` and `ai-core`, and asserts the public widget values are that `craft_ai_core` identity. Its locked check and one draw test passed with one `craft-ai-core` identity and the pinned egui family; the rendererless root leaves the winit patches unused. |
|
||||
| Font policy | The library keeps `egui` at `default-features = false`. Tests/examples opt into bundled `default_fonts`; the snapshot suite installs `FontDefinitions::default()` explicitly and keeps kittest's missing-glyph panic. A host supplies fonts or gets them through eframe; production does not force them. |
|
||||
| Test-only features | `egui_kittest` uses `snapshot` + `wgpu` only as a dev dependency. `eframe` stays a dev dependency with `default_fonts`, `glow`, `x11`, `wayland`. No new public feature flag. |
|
||||
| Duplicate core policy | This workspace and the smoke root use one path `craft-ai-core`. Do not combine it with `craft-libs/crates/ai-core`; replacing the local copy remains separate `CORE-SYNC` work. |
|
||||
| Final headless qualification | Under managed job `egui037-ai-regression-mutation-20261024-2205`, **108 tests passed** with strict `clippy --all-targets` and `cargo fmt --all --check` green on Linux, then again after restoration. Five committed kittest snapshots are hash-stable. The `eg` group caught both compiled lifecycle mutants; changing the background from RGB(27,27,27) to RGB(30,30,30) failed the expected snapshot with 75,486 changed pixels. The locked consumer smoke passed one draw test with one `craft-ai-core` identity and the pinned egui family. An independent test-packet review passed. This is not evidence for native GUI, IME/clipboard, physical-GPU or performance acceptance. |
|
||||
| Still open | Native Linux X11/Wayland and Windows widget workflows (focus, keyboard, clipboard/IME, screenshots); macOS IME/Metal/termination remains an explicit blocker; physical-GPU and performance qualification; any app adoption; human review of this documentation and fresh explicit approval for publication. The non-widget mutation groups were not rerun in the final headless job. |
|
||||
|
||||
## 1. Baseline and provenance
|
||||
|
||||
### 1.1 Exact source and dependency baseline
|
||||
|
||||
| Item | Recorded value |
|
||||
| --- | --- |
|
||||
| repository / checkout | `storytold/craft-ai-integrations` at `/home/codex/work/CraftApps/egui037-migration-20261022/craft-ai-integrations` |
|
||||
| baseline commit / branch | `1a02afd6bed425bcd516d8de2f3dc3b29614b23d` (`feat(client): offer SAM 2.1 on Burn as an optional engine after rten (#91)`) / `codex/egui-037-plan-20261022` |
|
||||
| workspace manifest | `Cargo.toml`, resolver 3, members `crates/*`, edition 2024, workspace MSRV 1.90 |
|
||||
| `craft-ai-egui` MSRV | `rust-version = "1.95"` (`crates/ai-egui/Cargo.toml:9`) |
|
||||
| lockfile | `Cargo.lock`, format 4, 943 packages |
|
||||
| current egui family | `egui`, `eframe`, `egui_kittest`, `egui-winit`, `egui-wgpu`, `egui_glow`, `epaint`, `epaint_default_fonts`, `ecolor`, `emath` all 0.36.2 from crates.io |
|
||||
| current access/kittest | `accesskit` 0.24.1, `accesskit_consumer` 0.35.0, `kittest` 0.4.0 |
|
||||
| current winit / checksum | 0.30.13 / `a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d` |
|
||||
| current `egui` checksum | `dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba` |
|
||||
| upstream target / comparison | annotated tag `0.37.0` peeled commit `841a328ba2dd8f6996beb7189ebb7fc00e668a2a`; `0.36.2` peeled commit `49682f8baa058bf49e011035cfbd6e825f88a5ef` |
|
||||
| candidate egui | `aiongg/egui` `craft/0.37` at `a4f9a17f64a6bae61350fb85804c9df0d9865916`; research input only |
|
||||
| candidate winit | `aiongg/winit` `craft/0.30.13-lang` at `8b35388bd719e8cc86a95971ba0bc026c9a8f058`; research input only |
|
||||
| published winit base | `storytold/winit` `craft/0.30.13` at `ffae5198770d7730e718043f035a772b7dbc3b1b` |
|
||||
| shared contract baseline | `storytold/craft-libs` at `49190884278f16c9a9f5823d7698a8ffb5deb062` |
|
||||
| existing shared UI work | `storytold/craft-libs` PR 199 head `b68de8c5f4f1c060904d211a7b1320aa0f7bce78`; reference only |
|
||||
|
||||
The annotated tag object SHA is not the peeled commit SHA. Every future manifest and lock entry must use the peeled commit or
|
||||
the reviewed storytold fork commit, never a tag object, branch name, or `main`. The candidate branch names in the local
|
||||
checkouts (`ai-0.37`/`ai-lang`) differ from the provenance names above; only the SHAs are accepted pins.
|
||||
|
||||
### 1.2 Current package targets and entry points
|
||||
|
||||
`crates/ai-egui/Cargo.toml` declares one library package and no `[[bin]]`, feature table, `[patch]`, or vendored source. The
|
||||
workspace has no root examples directory; the two examples belong to the package.
|
||||
|
||||
- library target: `crates/ai-egui/src/lib.rs`
|
||||
- `examples/picker_demo.rs`: native eframe window, sample-only store
|
||||
- `examples/history_demo.rs`: native eframe window, sample log by default, optional real history read
|
||||
- integration tests: `consent_terms.rs`, `history_view.rs`, `native_setup_view.rs`, `panel.rs`, `picker_view.rs`,
|
||||
`popup_view.rs`, `widgets.rs`
|
||||
- unit tests inside `text.rs`, `history_text.rs`, `form.rs`
|
||||
- deliberate-bug harness: `tools/mutation/`, groups `ce` (consent), `nw` (native setup), `v2` (picker), `v3` (popup)
|
||||
|
||||
Normal dependencies are local path dependencies: `craft-ai-core`, `craft-ai-client`, `craft-ai-features`
|
||||
(`crates/ai-egui/Cargo.toml:12-14`) and `egui = "0.36"` (`:15`). Dev dependencies are `egui_kittest = "0.36"` (`:18`),
|
||||
`eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }` (`:19`),
|
||||
`craft-ai-native`, `craft-ai-features`, `tempfile`, `serde_json` (`:20-23`).
|
||||
|
||||
There is no optional `egui` feature. `craft-ai-client` has optional `candle`, `rten` and `burn` features, but `ai-egui` does
|
||||
not enable them. Native-setup tests remove `candle` from `ProviderFactory::standard()` and register a fake adapter, so they
|
||||
never exercise a real engine download or inference.
|
||||
|
||||
### 1.3 Consumers and the duplicate-core constraint
|
||||
|
||||
Inspection of all 15 app workspaces in the planning tree, including every `Cargo.toml` and `.rs` file under `crates/`,
|
||||
`apps/`, and `xtask/`, found **no app dependency on `craft-ai-egui`, no import of `craft_ai_egui`, and no app manifest that
|
||||
depends on any `craft-ai-*` package**.
|
||||
|
||||
| Consumer | Egui state in its current checkout | `craft-ai-egui` dependency |
|
||||
| --- | --- | --- |
|
||||
| CADCraft `1977a493...` | direct `egui`/`eframe`/`egui_kittest` 0.37; `craft-ui` at 0.36 revision `5c874d5...`; two egui versions in lock | none |
|
||||
| SoundCraft `52471b37...` | direct 0.37; `craft-ui` at `5c874d5...`; two egui versions in lock | none |
|
||||
| DesignCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft, PhotoCraft, VectorCraft | 0.36.2; `craft-ui` `5c874d5...` (PdfCraft also vendors egui-wgpu/winit pieces; VectorCraft also vendors windows-link) | none |
|
||||
| GridCraft, WordCraft | 0.36.2; `craft-ui` `d27646a5...` | none |
|
||||
| DeckCraft, FigCraft | 0.36.2; no `craft-ui` | none |
|
||||
| Craft Launcher | 0.36.2; own launcher UI | none |
|
||||
| ArtCraft Native `800217ed...` (repository root) | 0.36.2; `eframe`, `egui_extras`, `kittest` | none |
|
||||
|
||||
`docs/LAYOUT.md` says PhotoCraft and the launcher “reach into it by path today”. That is not reproduced in the exact
|
||||
checkouts above. Treat the sentence as stale or as describing an external branch; it must not be used as evidence of a shared
|
||||
widget consumer. Any future adoption is a separate integration with its own plan, dependency pin, font policy and UI tests.
|
||||
|
||||
**Duplicate core is a future-adoption constraint, not an egui deliverable.** `craft-ai-egui` uses the local `craft-ai-core`
|
||||
path (`crates/ai-egui/Cargo.toml:12`; package at `crates/ai-core/Cargo.toml:2`), while `craft-libs` contains another crate
|
||||
with the same package name (`craft-libs/crates/ai-core/Cargo.toml:2`). `docs/CORE-SYNC.md:3-5,28-33` already records that the
|
||||
copies are not interchangeable and lists contract additions that have not moved upstream. An app that adopted both would have
|
||||
two package identities and distinct public types (`ConnectionId`, `DraftError`, …), and `cargo tree -d` would show the
|
||||
duplicate.
|
||||
|
||||
The migration therefore does not merge the two implementations and does not switch the local core to a craft-libs pin as a
|
||||
side effect. The AI-2 consumer-smoke workspace must prove the intended identity instead: it path-depends on `crates/ai-egui`
|
||||
and this workspace's `crates/ai-core`, resolves exactly one `craft-ai-core` package in `cargo tree -d`, and includes a
|
||||
type-level assertion that the widget-facing `ConnectionDraft`, `ConnectionId` and `DraftError` values are the same core
|
||||
types. Switching to one authoritative core needs separate `CORE-SYNC` work, its own review, old-store round-trip tests and a
|
||||
pinned upstream revision; it is not part of the egui migration.
|
||||
|
||||
## 2. What actually changes in egui 0.37
|
||||
|
||||
These are released 0.37 behavior, not fork behavior. The table separates source-confirmed changes from risks that need fresh
|
||||
measurement.
|
||||
|
||||
| Change | Concrete impact here | Source anchor |
|
||||
| --- | --- | --- |
|
||||
| `default_fonts` is no longer an egui default | Current `egui = "0.36"` silently turns on bundled fonts. With 0.37 the same string would stop bundling them; `default-features = false` records intent but is not proof of the resolved graph. | `crates/egui/Cargo.toml` 0.37.0 `[features]`; PR #8583 |
|
||||
| `WidgetType` is replaced by `accesskit::Role`; input widgets need accessible names | `connection_form`’s unlabelled Name field fails kittest’s new accessibility check; every input name/role must be explicit. | `crates/egui/src/lib.rs:642-657`; `crates/egui_kittest/src/accessibility.rs`; PRs #8555, #8589 |
|
||||
| `Response::labelled_by`, `Ui::label_inputs_by`, `Ui::name_inputs` | Use the visible heading `Response::labelled_by(heading.id)` or `ui.label_inputs_by(...)`; `TextEdit` has `id_salt`/`id_source` but no `labelled_by` builder. | candidate `crates/egui/src/response.rs:1065-1074`, `ui.rs:1001-1034`; PRs #8604, #8608 |
|
||||
| `Id::new`/`AsId` deprecated; `Ui::id` renamed `scope_id` | This crate does not call `Id::new`, `Ui::id`, or `AsId`. Existing `ComboBox::from_id_salt` and `ScrollArea::id_salt` remain right. Add salts for text edits and list scroll areas. | `crates/egui/src/id.rs`; `crates/egui/src/ui.rs`; PRs #8534, #8542, #8586 |
|
||||
| `ComboBox` is atom-based; `selectable_label` takes `IntoAtoms` | `picker::model_row` still has a `LayoutJob -> WidgetText -> Atom` path, but multi-line layout and selected styling must be revalidated, not assumed identical. | `combo_box.rs`; `atomics/atom_kind.rs`; PR #8618 |
|
||||
| Style defaults are roomier | Fixed `ROW_HEIGHT = 20.0` (`history.rs:38`) and the fixed `90.0` reserve (`progress.rs:33`) can clip or crowd after 0.37. Recompute from style or verify bounds. | `style.rs`; PRs #8617, #8445 |
|
||||
| `Context::run_ui`/`run_pass`/`root_ui`/`run_logic` | All widgets already take `&mut Ui`; keep that. Never call `Context::root_ui` from inside `App::ui`/`run_ui` (documented panic). | `context.rs:950-1055`, `:1174-1202`; PR #8535 |
|
||||
| Screenshot is callback-based | No screenshot request exists here. Do not add a generic screenshot bridge or helper. | `data/screenshot_callback.rs`; PR #8489 |
|
||||
| System font provider / missing-glyph policy | Production keeps default tofu behavior rather than panic. Tests panic on missing glyphs and install fonts explicitly. | `egui_kittest/src/builder.rs:39-51,88-101`; PRs #8512, #8601 |
|
||||
| PRIMARY/clipboard features | No clipboard calls, `arboard`, or PRIMARY reads exist in `ai-egui`. TextEdit clipboard behavior is host-inherited; this crate needs no clipboard adapter. | `egui-winit/src/clipboard.rs`; PR #8428 |
|
||||
| Text tessellation #8731 / DX12 #8736 | Text-heavy fixtures may expose #8731 as slower tessellation, not wrong output. `ai-egui` owns no wgpu renderer; #8736 belongs to an app/eframe. | upstream #8731, #8736 (external reports) |
|
||||
| TextEdit click-away #8763 / PR #8764 | `ConnectionPanel` does not read a TextEdit selection from a button action, so no in-crate workaround. PR #8764 is **open/unmerged**; 0.37.0 does not include the fix. | upstream #8763; unmerged #8764 |
|
||||
| Hidden parent/child #8748 | No viewport, docking, or child-window code here. #8748 is closed/unmerged and is not behavior in 0.37.0. | upstream #8748 |
|
||||
|
||||
## 3. Fork, craft-libs PR 199, vendoring, and publication
|
||||
|
||||
### 3.1 Suite fork and winit prerequisites
|
||||
|
||||
The candidate fork (`aiongg/egui` `craft/0.37` at `a4f9a17f...`) is based on upstream 0.37.0 and contains E1–E21, including
|
||||
E4 Wayland clipboard, E6/E21 IME, E7/E19 keyboard layouts and E21 delete-surrounding-bytes. The fork workspace patches winit
|
||||
by a movable branch; Cargo does not propagate a dependency’s `[patch]` section.
|
||||
|
||||
**Final approved reachable storytold revisions are prerequisites, and their SHAs cannot be invented before
|
||||
implementation/publication.** The production graph must pin the reviewed storytold egui family and winit by full immutable
|
||||
commits that contain at least:
|
||||
|
||||
- **W15P native PRIMARY** and separate **E4/P2b**: E4 regular clipboard plus the winit-owned per-seat PRIMARY path on the
|
||||
existing connection/event queue. W15P/P2b accept a fresh pointer or keyboard triggering serial for the focused seat, claim
|
||||
synchronously while fresh, and serve only asynchronously (total 2 s/16 MiB, 4 readers/2 writers/queue 8, live
|
||||
owner-specific waker, canceled/stale results discarded). Egui records viewport/focus/input generation and pastes once only
|
||||
if the target is still current.
|
||||
- **NEW E21b/P7b IME state — see the complete shared contract:** authoritative detail is egui plan E21b, winit plan W27 and
|
||||
the suite rollout's E21b/W27 contract; this crate only consumes built-in `TextEdit`. Non-negotiables: owner/
|
||||
context generation even for `Unavailable`; empty `Available` distinct and sent as an explicit empty payload; committed
|
||||
disable/enable clears caches on owner/unavailable transition; latest completed pass including `None` is authoritative;
|
||||
current-epoch `done`-serial mismatch applies incoming edits once and defers outgoing until match, obsolete epochs
|
||||
invalidate; new bounded payload allocates no whole document. Built-in `TextEdit` emits logical UTF-8 byte cursor/anchor
|
||||
plus remapped preedit; egui-winit sends it through W27 on Wayland only. New prerequisite, not existing E21/W27 behavior.
|
||||
- The companion winit W15/W26/W27 behavior; E4 and E21 pair with them and must not be taken alone.
|
||||
|
||||
The atomic production unit is the complete P8 egui revision (P2b, P7 held-key pairing, P7b/E21b, P7c selected #8763/#8748
|
||||
fixes, justified performance fixes) plus the completed winit revision (W15P, W27 lifecycle). No stock/intermediate pin.
|
||||
|
||||
`ai-egui` uses only standard widgets (`Ui`, `TextEdit`, `ComboBox`, `ScrollArea`, `Label`, `Button`, `ProgressBar`,
|
||||
`RichText`, `LayoutJob`). It calls no fork-only API such as `Style::right_to_left`, `RawInput::latin_layout_chars`,
|
||||
`ImeEvent::DeleteSurroundingBytes`, or `Context::set_language`. Therefore its source stays stock-compatible, but its
|
||||
production manifest must resolve the coherent fork family. Built-in TextEdit users inherit the E21b producer; this crate has
|
||||
no custom IME producer, so it does not add a model/preedit/byte-index adapter or set `None` placeholders. Those adapters
|
||||
belong to the eight custom editors in the consumer apps.
|
||||
|
||||
### 3.2 craft-libs PR 199
|
||||
|
||||
PR 199 is useful prior art for the `WidgetType` to `accesskit::Role` mapping, `Id::new` to `Id::unique` / `Ui::id` to
|
||||
`Ui::scope_id` renames, and kittest’s missing-glyph panic including the `⋮` fixture problem. It does not apply mechanically
|
||||
here: `ai-egui` does not depend on `craft-ui`, has no `Id::new`/`Ui::id` calls, no `⋮` fixture or icon font, and its package
|
||||
is `publish = false`.
|
||||
|
||||
**Do not merge its stale trees.** PR 199 head `b68de8c` (and the historical audit tree `ec8840a`) is stale relative to
|
||||
`4919088`: it lacks current `craft-platform` and reverts security, settings, docking and printer fixes. Reuse only the
|
||||
API-port hunks from `8614e4a04fae516fba16c22b31ff1008cb6be10b` and README changes from `1764bca` on current `4919088`,
|
||||
preserving intervening fixes and provenance. Neither tree qualifies the resulting source. Use PR 199 only as evidence of API
|
||||
renames and harness changes; do not copy its lockfile, merge commit, or unrelated fixture edits.
|
||||
|
||||
### 3.3 Vendors, patches, and package identity
|
||||
|
||||
Inventory of this checkout: no `vendor/` directory; no `[patch.crates-io]` or other `[patch]` in root or crate manifests; no
|
||||
git dependencies; `Cargo.lock` contains the egui family at registry 0.36.2 and exactly one winit 0.30.13. `egui-wgpu` appears
|
||||
in the lock, but the selected dev feature set is `glow`; lock presence is not proof that a wgpu backend is active.
|
||||
|
||||
There is no vendor fix to preserve or remove here. Do not add unrelated vendor changes. PdfCraft and VectorCraft vendors are
|
||||
not dependencies of this crate and are handled in their own plans.
|
||||
|
||||
All workspace packages are `publish = false`. Publication reachability therefore means:
|
||||
|
||||
- app consumers pin this repository by immutable git revision (or a local path only in private development), not a crates.io
|
||||
version;
|
||||
- every consumer root repeats the egui/winit `[patch]` entries, because patches do not propagate;
|
||||
- no machine-local path patch, branch, or tag object may appear in a shipped manifest;
|
||||
- a consumer-smoke workspace proves the exact patch recipe, because this workspace’s patch cannot be inherited by consumers.
|
||||
|
||||
## 4. Required implementation changes
|
||||
|
||||
No change below is authorized yet; this is the reviewable design.
|
||||
|
||||
### 4.1 Manifest, lockfile, and feature policy
|
||||
|
||||
The production dependency becomes the approved storytold egui revision, with an explicit font policy. A template (the `rev`
|
||||
values are filled only from the published approval, never invented):
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
egui = { git = "https://github.com/storytold/egui", rev = "<approved-storytold-egui>", default-features = false }
|
||||
```
|
||||
|
||||
The root and the consumer-smoke root need `[patch.crates-io]` entries for every family package that actually resolves:
|
||||
`egui`, `epaint`, `ecolor`, `emath`, `epaint_default_fonts`, `eframe`, `egui-winit`, `egui_glow`, `egui-wgpu`,
|
||||
`egui_kittest`, plus `winit`. Include `egui-wgpu` and `egui_kittest` because the mandatory pixel suite uses
|
||||
`egui_kittest/wgpu`; include `egui_glow` because the examples use eframe’s glow backend. Do not patch or add packages the
|
||||
root does not resolve.
|
||||
|
||||
Policy:
|
||||
|
||||
- Keep `eframe` as a dev dependency for examples with the same non-default 0.37 features: `default_fonts`, `glow`, `x11`,
|
||||
`wayland`. Do not add `wgpu`, `persistence`, `accesskit` or `system_fonts`; each is a separate capability decision.
|
||||
- Keep `egui_kittest` as a dev dependency and add `snapshot` + `wgpu` for the pixel suite. Its normal `egui` dependency
|
||||
inherits `default-features = false` (`crates/egui_kittest/Cargo.toml:39-42`); only kittest's own `[dev-dependencies]`
|
||||
enable `default_fonts`/`monochrome_emoji_fonts` (`:65-68`), which do not propagate downstream. This test root gets fonts
|
||||
from its own `eframe` dev dependency (`crates/eframe/Cargo.toml:56` → `egui/default_fonts`); otherwise add that dev feature.
|
||||
- Keep `craft-ai-egui` at `rust-version = "1.95"`. The workspace remains 1.90 for non-egui crates; egui 0.37’s MSRV is 1.95.
|
||||
- No new public feature flags. Optional engine features remain on `craft-ai-client`, not here.
|
||||
|
||||
**Resolve features per root; a direct `default-features = false` alone proves nothing.** Run `cargo tree -e features` and
|
||||
`cargo metadata` for the production library, the test/example root and the consumer-smoke root. The production graph must
|
||||
not silently enable `egui/default_fonts` through eframe, another member or feature unification; tests/examples use bundled
|
||||
fonts and the smoke root declares its own policy. A consumer may enable fonts through eframe or its own provider. Add a
|
||||
host-font/no-tofu test and do not treat the direct flag as resolved-graph evidence.
|
||||
|
||||
Lockfile acceptance:
|
||||
|
||||
- `Cargo.lock` regenerated by Cargo under a managed `task.py run` job, never edited by hand.
|
||||
- Exactly one source for `egui`, `epaint`, `ecolor`, `emath`, `epaint_default_fonts`, `eframe`, `egui-winit`, `egui-wgpu`,
|
||||
`egui_glow`, and `egui_kittest`; exactly one `winit`.
|
||||
- No registry egui copy beside the selected git copy. Package IDs must use the approved storytold revision, not an aiongg
|
||||
branch. `cargo tree -d`, `cargo tree -i`, and `cargo metadata` results are review evidence.
|
||||
|
||||
### 4.2 Accessible names, roles, and stable IDs
|
||||
|
||||
Current break under 0.37:
|
||||
|
||||
- `form.rs:185-186` draws a `TextEdit` for the connection name with no hint text and no label association; kittest treats it
|
||||
as an unnamed `TextInput` and fails.
|
||||
- Address and Key have placeholder hints, so they would be named by placeholder, but that is a weak, mutable name; the
|
||||
visible headings are not associated.
|
||||
- `form.rs:150` uses a preset `ComboBox` whose selected text is a value, not a stable field name.
|
||||
- `history.rs:136,166` and `status.rs:89` use combos whose selected values or label-derived salts are not stable field names.
|
||||
|
||||
Refactor:
|
||||
|
||||
1. Make the `heading` helper return `egui::Response`. Use the actual API
|
||||
`ui.add(TextEdit::singleline(...).id_salt(...)).labelled_by(heading.id)` for Name, Address and Key, or wrap the field in
|
||||
`ui.label_inputs_by(heading.id, |ui| ...)`. Do **not** write `TextEdit::labelled_by`: `TextEdit` has no such builder
|
||||
method.
|
||||
2. Add explicit `.id_salt(...)` to the three TextEdits: `"craft-ai-connection-name"`, `"craft-ai-connection-address"`,
|
||||
`"craft-ai-connection-key"`. This keeps focus/selection on the same field across row insertion or translation.
|
||||
3. Give the preset combo a visible `Start from` label response and call `.labelled_by` on `show_ui(...).response`. For
|
||||
history/status combos use `.accessible_name(...)`: “Filter by app”, “Filter by model”, “Date range”, and a `view.label`-based “model” name for the status picker.
|
||||
4. Add a stable salt to the history scroll area. `AsIdSalt` requires `Hash + Debug` (candidate
|
||||
`crates/egui/src/id_salt.rs:5-9`), and `Tab` currently derives only `Clone, Copy, Debug, Default, PartialEq, Eq`
|
||||
(`history.rs:74`). Either add `Hash` to `Tab` and use `.id_salt(("history-list", state.tab))`, or add an explicit stable
|
||||
enum key and use `.id_salt(("history-list", state.tab.key()))`. Do not depend on `Debug` formatting. The grouped lists get
|
||||
a stable `"history-list"`/tab salt; `picker` already uses `("craft-ai-picker", view.tool)`.
|
||||
5. Do not change the public `StatusView` shape. The label-based ID remains a documented limitation; a later localization PR
|
||||
may add a stable role ID with its own API decision and migration note.
|
||||
6. Assert roles and names for every input: `TextInput`, `PasswordInput`, `ComboBox`, `CheckBox`, `Button`, `RadioButton`;
|
||||
check disabled and selected state. Do not disable `HarnessBuilder::with_accessibility_check` globally.
|
||||
|
||||
Compatibility contract: public signatures/action enums remain unchanged; accessible names do not change pixels; explicit ID
|
||||
salts change widget identity relative to old auto IDs, which is acceptable because the package has no current app consumers.
|
||||
Document it and keep the salts stable.
|
||||
|
||||
### 4.3 ConnectionPanel lifecycle: logic/UI split and once-only side effects
|
||||
|
||||
Current path (`panel.rs:63-121`) does everything inside `show(&mut Ui)`: polls `TestRunner`, sets the form’s test line,
|
||||
requests a 100 ms repaint, starts a network test on `FormEvent::Test`, and writes the shared store on `FormEvent::Save`. That
|
||||
is three side-effect classes in a UI callback. Under the pinned 0.36/0.37 eframe integration, **both native and web call
|
||||
`App::logic` and `App::ui` inside the `run_ui` closure, and both can call logic once per layout pass**. After a normally
|
||||
completed `run_ui`, `current_pass_index()` is zero; `end_pass` takes the output and `run_dyn` restores only before the next
|
||||
pass. Visibility is not a phase: `RequestPaintWhileHidden` can run UI while minimized/occluded, and a false `visible()` does not prove a logic-only callback. A first-pass guard is therefore not a hidden-work fix; prefer consumed
|
||||
queues, generation identity and monotonic deadlines, and test command/side-effect outcomes.
|
||||
|
||||
Refactor to an explicit command queue:
|
||||
|
||||
```rust
|
||||
enum PanelCommand {
|
||||
StartTest { target: String, draft: ConnectionDraft, generation: u64 },
|
||||
Save(ConnectionDraft),
|
||||
}
|
||||
|
||||
pub struct ConnectionPanel {
|
||||
manager: ConnectionManager,
|
||||
runner: TestRunner,
|
||||
form: ConnectionFormState,
|
||||
testing: Option<String>,
|
||||
generation: u64,
|
||||
pending: Option<PanelCommand>,
|
||||
}
|
||||
pub fn poll(&mut self, ctx: &egui::Context) -> Option<PanelEvent>;
|
||||
pub fn ui(&mut self, ui: &mut Ui) -> Option<PanelEvent>;
|
||||
pub fn show(&mut self, ui: &mut Ui) -> Option<PanelEvent>; // compatibility: poll then ui
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- `ui` draws only. On Test it records one `StartTest` command, **sets `testing` to the captured endpoint immediately**, sets
|
||||
`form.test = Running`, clears the old error, and calls `ui.ctx().request_repaint()`. Setting `testing` immediately is
|
||||
required: the current form disables Test when `testing` is `Some` (`form.rs:250-253`), so a second UI pass in the same
|
||||
frame sees the button disabled and cannot enqueue a second start. `pending` is an additional admission guard. `ui` does not
|
||||
spawn a thread.
|
||||
- On Save, `ui` records one `Save` with a captured draft snapshot, requests a repaint so the next poll runs, and does not
|
||||
write the store. On Cancel it returns `Closed`, which `show` queues after any pending `Saved`.
|
||||
- `poll` takes `&egui::Context` so it can request a repaint after 100 ms while a test is active even from hidden logic. It
|
||||
drains at most one pending command, then polls `TestRunner` once. It starts the runner exactly once. It updates the form
|
||||
only when the captured endpoint and generation still match the current form, preserving the provenance check
|
||||
(`panel.rs:65-92`).
|
||||
- Editing the address increments the generation and cancels/invalidates the in-flight result; a late answer for a changed
|
||||
endpoint or canceled generation is dropped and leaves the form untested.
|
||||
- `poll` returns `PanelEvent::Saved` only after `manager.save` succeeds; it stores the error in the form otherwise. `Closed`
|
||||
remains UI-returned.
|
||||
- `show` calls `poll(ui.ctx())` first, then `ui`. Events enter a FIFO `VecDeque<PanelEvent>`; `show` returns one per call,
|
||||
queueing `Saved` before `Closed` when both occur in one frame, so a host that keeps calling until `Closed`/empty loses
|
||||
neither. Click actions execute on the next poll/frame, which is acceptable for an interactive loop and makes repeated
|
||||
passes idempotent. Tests call `Harness::run`/`step` so the next frame occurs.
|
||||
- Every app that wants correct hidden-window behavior calls `panel.poll(ctx)` from `App::logic` and `panel.ui(ui)` from
|
||||
`App::ui`; `show` remains for simple/UI-only hosts.
|
||||
- Do not put `ConnectionPanel` state in `egui::Memory` or a `Context` bridge. It is an app-owned object passed to both hooks.
|
||||
|
||||
Examples get the same discipline:
|
||||
|
||||
- `picker_demo` calls `ToolDesk::choose` in `App::ui` (`examples/picker_demo.rs:254-269`). Queue `PickerAction`/`PopupAction`
|
||||
in `pending` and drain it in `App::logic` once; `refresh()` stays in logic.
|
||||
- `history_demo` writes `history-export.csv` in `App::ui` (`examples/history_demo.rs:196-205`). Queue `HistoryAction::Export`
|
||||
and write in logic; `Clear` only clears the displayed log and stays local.
|
||||
- `native_model_setup` already uses `SetupSession`; `begin` refuses when busy. The host still queues the returned
|
||||
`NativeSetupAction::Work` once and starts one worker; document and test the action count.
|
||||
- There is no playback/audio, autosave, undo transaction, or command history here. Do not invent one. The only side effects
|
||||
are the connection test, store save, setup worker start, and demo CSV export.
|
||||
|
||||
No generic `Context` bridge: all widgets receive the existing `&mut Ui`; `Context::root_ui` would panic inside `App::ui`. No
|
||||
shared helper is needed for only this crate.
|
||||
|
||||
### 4.4 Layout, row height, and narrow windows
|
||||
|
||||
0.37’s roomier defaults invalidate two hard-coded assumptions:
|
||||
|
||||
- `history.rs:38` uses `ROW_HEIGHT = 20.0` with `ScrollArea::show_rows`. The parameter is `row_height_sans_spacing`;
|
||||
`show_rows` itself adds `ui.spacing().item_spacing.y` (`crates/egui/src/containers/scroll_area.rs:1098-1106`). Pass only
|
||||
the content height excluding item spacing—normally `ui.text_style_height(&egui::TextStyle::Body)`—and do not add label
|
||||
spacing a second time. If a custom gap is needed, document and subtract it. Add first-row, middle and bottom scroll
|
||||
assertions, not just row containment. Test 1x/1.5x/2x and an actually larger text style; `pixels_per_point` does not by
|
||||
itself enlarge logical text.
|
||||
- `progress.rs:33` reserves a fixed 90 points for Cancel. Use the actual button minimum width
|
||||
(`ui.spacing().interact_size.x`/button galley) plus spacing, or wrap Cancel below the bar at narrow widths. Add a 320×480
|
||||
test that Cancel stays inside the clip rect and is clickable.
|
||||
- `status.rs:67` and `picker.rs:125` use unwrapped horizontal rows. Change action/header rows to `horizontal_wrapped` where
|
||||
commands would otherwise be pushed off-screen, preserving order and labels. `history.rs:157` already wraps; its footer row
|
||||
(`history.rs:229`) should wrap too.
|
||||
- Keep panel ownership in the app. This crate creates no `SidePanel`, `TopBottomPanel`, or `CentralPanel`; examples use only
|
||||
`CentralPanel::default().show(ui, ...)`. Do not add a panel or docking abstraction.
|
||||
- Do not change `LIST_HEIGHT = 380.0` in the picker or `LIST_HEIGHT = 320.0` in history without a measured reason; they are
|
||||
deliberate bounded lists.
|
||||
- Preserve command reachability: a wrapped button stays visible, enabled/disabled as before, and keyboard-reachable.
|
||||
|
||||
### 4.5 Fonts and glyph coverage
|
||||
|
||||
Source strings are ASCII except escaped punctuation: `\u{2022}` bullet, `\u{2026}` ellipsis, `\u{2014}` em dash, `\u{00b7}`
|
||||
middle dot. These are ordinary Latin-font glyphs, not color emoji, private-use icons, CJK, or combining marks. Still, 0.37’s
|
||||
font stack changed and kittest panics on a missing glyph.
|
||||
|
||||
Rules:
|
||||
|
||||
- Production `egui` does not force bundled fonts. A consumer that supplies fonts keeps them; one that relies on eframe’s
|
||||
`default_fonts` gets them through eframe. That is not resolved-feature evidence; §4.1 requires per-root checks. Do not
|
||||
enable `system_fonts` by default; #8512 reports startup discovery cost.
|
||||
- Tests use bundled default fonts and keep `MissingGlyphPolicy::Panic`. Add an explicit glyph test for the four code points;
|
||||
if one test deliberately needs tofu, scope `allow_missing_glyphs` to it and say why.
|
||||
- No emoji or image glyph is rendered here, so there is no in-crate texture sampling to change. Do not add an image for
|
||||
decoration.
|
||||
- If a future fixture uses Arabic/Thai/CJK/Hebrew/emoji, use the licensed pinned set in the egui plan §10.6 (craft-fonts
|
||||
`2481a8a...`) and record source, license and SHA256; do not copy proprietary/app font assets.
|
||||
|
||||
### 4.6 Screenshots, clipboard, IME, and viewports
|
||||
|
||||
- No screenshot request, `UserData` correlation or screenshot helper exists here; pixel tests use kittest snapshots. This
|
||||
library has no screenshot consumer and does not adopt the shared completion-slot helper.
|
||||
- Shared capture contract: the suite rollout's `Shared components: justified boundaries` section is authoritative; no
|
||||
capture feature is added here. Defaults are 4 outstanding, 128 MiB/image, 256 MiB aggregate (stricter caps allowed);
|
||||
check `width*height*4` before issue, record issuance UI geometry/epoch, and cancel/retry on any resize/DPI/zoom/surface
|
||||
change (even back) before save. Callback carries pixels only; app owns strong slot+waker, callback is weak, unlocks
|
||||
before repaint and never wakes itself. The command forces a paint; queued-not-issued hidden capture/settling needs
|
||||
`RequestPaintWhileHidden` from logic. Adoption is atomic: the eleven `craft-ui` consumers take CL-1 and CL-2F in one
|
||||
dependency stage; Design/Grid use reviewed CL-2 provisional adapters before CL-2F freezes, and any incompatible
|
||||
`craft-ui` bump joins that stage. Fourteen capture consumers: eleven shared plus DeckCraft/FigCraft/ArtCraft Native
|
||||
local; launcher and this library have none.
|
||||
- No clipboard, PRIMARY, file-drop, or pen code exists. Standard TextEdit receives host clipboard/IME events; do not
|
||||
intercept or translate them. Built-in TextEdit inherits the fork’s E21b producer. Native clipboard, PRIMARY, IME and focus
|
||||
remain host gates; this crate has no custom IME producer to adapt.
|
||||
- No child viewport, docking, or hidden-window creation exists; #8748 does not apply. Do not call `Context::root_ui` from a
|
||||
widget or example; the existing `&mut Ui` is the root UI.
|
||||
|
||||
### 4.7 API compatibility summary
|
||||
|
||||
| Contract | Decision |
|
||||
| --- | --- |
|
||||
| Public widget functions | Keep signatures and return types. |
|
||||
| `PanelEvent` variants | Keep `Saved(ConnectionId)` and `Closed`; add no exhaustive-match-breaking variant. |
|
||||
| `ConnectionPanel::show` | Keep as compatibility helper; add `poll` and `ui`. Save/test now execute on the following poll, not inside the click frame. |
|
||||
| `ConnectionPanel::form`, `ConnectionFormState`, `HistoryViewState` fields | Keep. |
|
||||
| `StatusView` fields | Keep for this migration; stable role-ID work is a separate API decision. |
|
||||
| Action enums/constants, persistence format | Keep. `Saved` still reports `ConnectionId`; shared-store schema unchanged. |
|
||||
| Undo/commands | No undo/command system exists; no new one. |
|
||||
| Feature flags | No new public flags. Snapshot/wgpu are dev-only. |
|
||||
| Fonts | Production stops forcing bundled fonts; hosts own font policy. |
|
||||
| Roles/names/IDs | Add accessible names and associations; add stable text/list salts; keep picker/status/combo salts semantically stable. |
|
||||
| Screenshots | No screenshot API or helper; pixel goldens use kittest. |
|
||||
|
||||
## 5. Staged pull requests, dependency DAG, and rollback
|
||||
|
||||
All PRs are local drafts until a fresh independent review and explicit publication approval. Numbers are order labels, not
|
||||
publication permission. The former separate stock-to-fork stage is absorbed into AI-2; there is no stock-0.37 production
|
||||
stage.
|
||||
|
||||
### 5.1 PR series
|
||||
|
||||
| PR | Scope | Depends on | Rollback |
|
||||
| --- | --- | --- | --- |
|
||||
| AI-1 | Baseline harness on 0.36.2: deterministic fixtures, timing probe, geometry/accessibility assertions, and committed 0.36.2 pixel goldens using the qualified renderer below. No production edits. | plan review | delete new test/example files; no production change |
|
||||
| AI-2 | **Atomic coherent graph and source compatibility:** manifest/lock/root `[patch]` for the approved storytold egui family + winit, explicit font/feature resolution per root, consumer-smoke workspace with one core identity and `cargo tree -d`, accessible names/`labelled_by`, stable ID salts, missing-glyph tests, kittest 0.37, candidate pixel run with reviewed diffs. Stock 0.37 is only a measurement/CI control, never a merged production stage. | AI-1; complete reviewed/published storytold P8 egui (P2b, P7 held-key pairing, P7b/E21b, P7c #8763/#8748 and justified perf fixes) plus completed winit (W15P, W27 lifecycle) | revert the whole stage to the 0.36.2 baseline; restore old lock/source |
|
||||
| AI-3 | Lifecycle/root-Ui refactor: `ConnectionPanel` command queue, immediate `testing` admission, `poll(ctx)`/`ui` split, generation/cancellation, examples’ action queues, hidden-logic and repeated-pass tests. | AI-2 | revert one commit; restore old `show` path and examples |
|
||||
| AI-4 | Responsive/visual hardening: row-height computation, wrapped action rows, progress width, expanded 320px and 1x/1.5x/2x pixel/geometry tests. | AI-2, AI-3 | revert UI-only commit; keep old constants/goldens |
|
||||
| AI-5 | Consumer contract/docs: update `README.md`, `docs/LAYOUT.md`, `docs/PLATFORM-CONTRACT.md`, and a consumer recipe with exact pins, patch repetition, font/feature policy, the duplicate-core future-adoption constraint, and the fact that no app imports this crate. | AI-2, AI-3, AI-4 | revert docs; code unaffected |
|
||||
|
||||
Dependency DAG:
|
||||
|
||||
```text
|
||||
AI-1 baseline 0.36.2 ───────┐
|
||||
├─► AI-2 ─► AI-3 ─► AI-4 ─► AI-5
|
||||
approved fork + winit ───────┘
|
||||
```
|
||||
|
||||
“Approved fork + winit” means that complete P8 egui revision plus the completed winit revision; stock 0.37 is only the measurement control.
|
||||
|
||||
App plans depend on AI-2/AI-3 only if they adopt `craft-ai-egui`; none currently does. Their own dependency reconciliation
|
||||
(CADCraft/SoundCraft’s two egui versions, craft-ui pins) is not an AI-egui deliverable.
|
||||
|
||||
### 5.2 Rollback
|
||||
|
||||
The old coherent set is the entire baseline: `Cargo.toml` and `Cargo.lock` at `1a02afd6...`; `crates/ai-egui`
|
||||
source/tests/examples at that commit; registry 0.36.2 egui family plus winit 0.30.13; no fork patch. Rollback is a revert of
|
||||
the whole stage, not a lone version swap and not a stock-0.37 intermediate. Preserve the old `Cargo.lock` and source SHA in
|
||||
evidence before the first dependency change. Do not change shared store/history/consent formats; no user-data migration is
|
||||
needed.
|
||||
|
||||
### 5.3 Publication reachability and pin checks
|
||||
|
||||
- The repository is not a registry package (`publish = false`). Consumers use a pinned git revision.
|
||||
- A consumer root that references the fork adds its own `[patch.crates-io]` entries for every egui-family package it actually
|
||||
uses plus winit; this repository’s patch is ignored by that consumer.
|
||||
- The consumer-smoke workspace (separate `[workspace]`, path dependency on `crates/ai-egui` and `crates/ai-core`, its own
|
||||
exact `[patch.crates-io]`) runs the same tests with the fork and proves the recipe a real app will copy. Its `cargo tree` duplicate check must show one `craft-ai-core` and one egui family/one winit, and its type assertions must use that core identity. It
|
||||
must not pull `craft-libs/crates/ai-core`.
|
||||
- Add a static check that rejects path patches in publication/manifests (except the intentional local path dependency in
|
||||
private development), branch/tag refs for egui/winit, a second registry egui family, and machine-local paths in
|
||||
docs/examples.
|
||||
- Do not assume a version match is a source match. `egui_plot`, `egui_dock`, etc. are absent here; do not add or patch them
|
||||
without a consumer.
|
||||
|
||||
## 6. Regression and acceptance tests
|
||||
|
||||
### 6.1 Repository gates
|
||||
|
||||
These are the existing repository requirements (`.github/workflows/ci.yml`) and run under the managed `maintenance/task.py` run/status/finish workflow for implementation, not from this planning turn:
|
||||
|
||||
```sh
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
cargo check -p craft-ai-core --target wasm32-unknown-unknown --locked
|
||||
cargo check -p craft-ai-features --target wasm32-unknown-unknown --locked
|
||||
python3 -m unittest discover -s tools/mutation -p 'test_*.py'
|
||||
cargo deny check advisories licenses sources
|
||||
```
|
||||
|
||||
Focused gates:
|
||||
|
||||
```sh
|
||||
cargo test -p craft-ai-egui --locked
|
||||
cargo test -p craft-ai-egui --test panel --test widgets --test picker_view --test history_view --test consent_terms --test native_setup_view --test popup_view --locked
|
||||
cargo run -p craft-ai-egui --example picker_demo
|
||||
cargo run -p craft-ai-egui --example history_demo
|
||||
```
|
||||
|
||||
No browser gate is claimed: `craft-ai-client`/`craft-ai-native` are native-only and `craft-ai-egui` is not a wasm target. The
|
||||
`ai-core`/`ai-features` wasm checks stay as workspace gates; they are not browser UI tests.
|
||||
|
||||
### 6.2 User-observable assertions
|
||||
|
||||
1. **Accessibility and keyboard.** Every visible input has a name and `Harness::unnamed_widgets()` is empty after each
|
||||
settled harness. Name/Address/Key expose correct roles/labels; Tab follows visual order. Password is `PasswordInput` and
|
||||
key text never appears in labels/queries. Enter/Space activates the focused button; disabled controls are disabled in the
|
||||
accesskit tree; hidden controls (Key when `uses_key` is false) disappear.
|
||||
2. **Stable identity and state.** Focusing Name, changing the test line/disclosure, and rerendering do not move focus/edit
|
||||
state. Typing in one field does not change another field’s text/selection. Picker scroll and row selection survive an
|
||||
unrelated update; changing a combo selection does not move focus.
|
||||
3. **Connection test lifecycle.** Test starts exactly one localhost request; a forced second UI pass cannot start another and
|
||||
sees Test disabled immediately. A late answer for a changed endpoint/generation is dropped; the form stays untested and no
|
||||
old models are saved. `poll` without drawing eventually reports the result. Hidden poll requests repaint only while
|
||||
testing and stops after completion. Editing/cancel clears the old result and a canceled generation never applies a late
|
||||
answer.
|
||||
4. **Save/close/export side effects.** Save produces one `PanelEvent::Saved` and one store write, including under a forced
|
||||
second pass. Invalid save keeps the form open with the same error and writes nothing. Cancel produces `Closed` and writes
|
||||
nothing. A save completing in the same frame as Cancel queues `Saved` then `Closed`; both are observed delivered. Export
|
||||
produces one CSV file, not one per pass; Clear still needs the explicit second confirmation. Native setup
|
||||
`Work` is returned once per admitted request; `busy` blocks a duplicate and a late completion is refused (existing tests).
|
||||
5. **Geometry and hit testing.** Picker: clicking the center of the first/last visible row chooses the intended model; a
|
||||
disabled group produces no action; Set up/Fix works. History: a 3,000-row fixture draws only visible rows; first/last
|
||||
visible lines match the filter; export/clear stay inside the clip at 320×480. Progress: Cancel is fully visible and
|
||||
clickable at 320×480; percentage/cost do not overlap. Status/picker/consent rows wrap rather than hiding commands at
|
||||
320px. At 1.0/1.5/2.0 pixels-per-point, rows do not overlap and controls stay inside clip rects.
|
||||
6. **Themes, fonts, text.** Dark/light themes use theme colors with no hard-coded foreground. The fixture covers U+2022,
|
||||
U+2026, U+2014, U+00B7; missing glyphs panic by default. No tofu in the fixed fixture; no claim about arbitrary user text
|
||||
or color emoji. RTL/CJK/combining tests are host-enabled only; the English strings claim no RTL support and add no
|
||||
fork-only RTL API.
|
||||
7. **Persistence/workflow.** Save a synthetic connection, close, and reopen a fresh `ConnectionPanel` on the same temporary
|
||||
store: connection and models are present; the secret key is only in the in-memory secret store. History filter/offset/tab
|
||||
and CSV columns are unchanged; old store files still load.
|
||||
|
||||
### 6.3 Pixel comparisons (mandatory)
|
||||
|
||||
A deterministic visual suite is a required AI-1/AI-2/AI-4 deliverable, not an optional question. Geometry and semantic tests
|
||||
do not catch changed styling, clipping, contrast, theme colors, or a visually broken wrapped control.
|
||||
|
||||
- Add `egui_kittest` dev features `snapshot` + `wgpu`. Use `HarnessBuilder::wgpu()` or
|
||||
`HarnessBuilder::wgpu_setup(WgpuSetup)` (wrapping `WgpuTestRenderer::from_setup`, `builder.rs:251-252`), not the default
|
||||
lazy renderer. Baseline 0.36.2 and candidate 0.37 use different kittest/egui-wgpu/wgpu versions: record those versions,
|
||||
adapter driver and snapshot settings; version-induced diffs are attribution limits, or add a stock-0.37 visual control.
|
||||
- **Qualify the adapter.** `WgpuTestRenderer` prefers a CPU adapter but can fall back to a physical GPU (candidate
|
||||
`crates/egui_kittest/src/wgpu.rs:30-55`). Record backend/device type/name/driver for every run and enforce a CPU adapter
|
||||
(for example lavapipe/software) for committed goldens. Physical-GPU images are a separate diagnostic row and must not
|
||||
update goldens. If the CPU adapter cannot be qualified, record that as an explicit blocker and require consumer-app
|
||||
screenshot evidence; do not leave the visual gate optional.
|
||||
- Fix the harness: 800×600 and 320×480, 1x/1.5x/2x, dark/light, explicit `Os`, bundled test fonts, animations disabled by
|
||||
kittest, stable initial state. Record the exact font files/hashes and license manifest.
|
||||
- One image per widget family: consent, status, progress, picker, popup/install, history jobs, history empty, connection
|
||||
form. Use kittest’s per-pixel threshold and a very small `max_failed_pixels`; do not raise tolerance to hide a real shift.
|
||||
- Baseline is captured at 0.36.2 before production edits. Candidate differences from 0.37 style changes are reviewed
|
||||
individually and recorded as intended. Never bulk-refresh goldens to make a failing suite green.
|
||||
- **Actual implementation note:** this checkout did not capture the 0.36.2 baseline images. The new
|
||||
`crates/ai-egui/tests/visual_snapshots.rs` suite renders the 0.37 candidate only; its five
|
||||
committed PNGs are candidate baselines, not before/after migration attribution. The final run
|
||||
kept all five hashes unchanged, and a pure-background RGB(27,27,27) to RGB(30,30,30) negative
|
||||
control failed as expected with 75,486 changed pixels.
|
||||
- Include a negative control: deliberately shift a row/button or remove an accessible name in a temporary mutation and
|
||||
confirm the visual or semantic test fails; restore source afterward.
|
||||
- Keep pixel tests separate from native screenshots. An offscreen kittest image does not prove native focus, IME, clipboard,
|
||||
or window behavior.
|
||||
|
||||
### 6.4 Native/platform evidence (explicit gates)
|
||||
|
||||
Native acceptance is blocking for this library’s standard widgets; headless kittest does not replace it. Use fixtures
|
||||
appropriate to a library with no document model: the two examples plus a synthetic connection save/reopen through the
|
||||
temporary shared store. Do not invent document, autosave, undo, or screenshot semantics.
|
||||
|
||||
| Row | Required evidence |
|
||||
| --- | --- |
|
||||
| Linux headless | Qualified kittest CPU tests. The host’s `/dev/dri/renderD128`/i915 is availability evidence only, not a qualified backend/compositor result. |
|
||||
| Linux X11 | Real session/window manager; recorded server/compositor identity; actual focus, keyboard, clipboard and TextEdit/IME behavior; example/panel screenshots. |
|
||||
| Linux Wayland | Real compositor and version (wlroots/KWin/Mutter); focus, keyboard, clipboard/PRIMARY where exercised, and E21b/P7b IME/outgoing surrounding text; screenshots and protocol trace where applicable. |
|
||||
| Windows | Uniquely owned managed clone with a visible desktop; examples plus a real connection panel: type into fields, run a localhost/scripted test, save a synthetic connection, reopen it, capture edited/saved/reopened screenshots; verify the active console, not session 0. |
|
||||
| macOS | Explicit unresolved gate: real IME/Metal/termination evidence or a recorded blocker; cross-compilation/kittest is not acceptance. |
|
||||
| Browser | Not applicable: the client stack is native-only; do not claim a browser UI pass. |
|
||||
|
||||
### 6.5 Deliberate-bug checks
|
||||
|
||||
Rerun the existing groups after the migration:
|
||||
|
||||
```sh
|
||||
python3 tools/mutation/mutation_check.py ce
|
||||
python3 tools/mutation/mutation_check.py nw
|
||||
python3 tools/mutation/mutation_check.py v2
|
||||
python3 tools/mutation/mutation_check.py v3
|
||||
```
|
||||
|
||||
Add a focused `panel`/`eg` group:
|
||||
|
||||
| Mutant | Test that must fail |
|
||||
| --- | --- |
|
||||
| Remove `labelled_by`/`accessible_name` from Name | accessibility test / `unnamed_widgets()` |
|
||||
| Give two TextEdits the same ID salt | stable-ID/focus test |
|
||||
| Execute `manager.save` directly in `ui` and again in `poll` | exactly-one-save test |
|
||||
| Clone instead of take `pending` | exactly-one-test/save test |
|
||||
| Start `TestRunner` in `ui` as well as `poll` | server request-count test |
|
||||
| Leave `testing` unset when enqueueing StartTest | immediate-disabled/second-pass test |
|
||||
| Ignore endpoint generation/cancellation | late-answer generation test |
|
||||
| Poll the runner only inside `ui` | hidden/logic-only poll test |
|
||||
| Remove the captured-address check | late-answer provenance test |
|
||||
| Revert `horizontal_wrapped` in status/picker | 320px command-reachability test |
|
||||
| Use a hard-coded 20px row **including** item spacing | row-overlap/clip test |
|
||||
| Remove the glyph test’s font setup | missing-glyph panic test |
|
||||
|
||||
A mutant that does not compile is not “caught.” Fix the mutant or the test; do not ignore a survivor. Run the group
|
||||
exclusively, restore source, and rerun the focused tests clean.
|
||||
|
||||
Final qualification run: the `eg` group caught both compiled lifecycle mutants
|
||||
(`save-frame-dedup-removed` and `answer-generation-ignored`), restored `panel.rs`, and the clean
|
||||
package tests passed again. The other mutation groups listed above were not rerun in that job.
|
||||
|
||||
## 7. Performance protocol
|
||||
|
||||
No performance result is claimed by this plan. The shared suite minimums apply and may be tightened, never loosened.
|
||||
|
||||
### 7.1 Workloads, measurement and gates
|
||||
|
||||
Record source/lock SHA and package IDs, per-root features, toolchain/profile, OS/kernel, CPU/RAM, GPU/driver and
|
||||
renderer/backend, session/compositor, DPI, theme, viewport, font files/hashes, fixture identity, warmups, sample counts and
|
||||
host load (no concurrent build/VM/agent).
|
||||
|
||||
Fixtures: (1) history 3,000 deterministic jobs, jobs/day/month grouping, empty/filtered, scroll top/middle/bottom, long
|
||||
labels, 1280×720 and 320×480, 1x/1.5x/2x, dark/light; (2) picker 20×20 models, mixed ready/needs-key/off, presets, 560×640
|
||||
and 320×480; (3) connection form blank/edit, 2 KB address, long pass/fail line, key shown/hidden, 100-character typing and
|
||||
localhost test; (4) status/consent/progress/popup small widgets with 20 choices/items; (5) 20 fresh example processes with
|
||||
first `Context` pass and font/window setup separate from compile time. Use synthetic data only; no
|
||||
provider/keychain/download. Script fixtures use the licensed pinned font set/hashes (§4.5, egui plan §10.6).
|
||||
|
||||
Measure wall time for `Context::run_ui` + `tessellate`, tessellation shape/vertex/index counts, galleys, allocations/peak RSS
|
||||
(and guest working set), frame distributions (≥1,000 frames, p50/p95/p99), idle repaint count/CPU over 10 s,
|
||||
Test/Save/Export/setup `begin` latency, startup/font-provider timing and raw samples.
|
||||
|
||||
Protocol: ≥10 warmups and 30 timed iterations; 20 startup processes; 1,000 frames; alternate baseline/candidate order with
|
||||
separate allocations/processes and identical compiler/profile/features/fonts/fixture/backend/viewport; software and real GPU
|
||||
are separate classes; no noisy workloads; retain logs/commands. Every first baseline report and investigated regression
|
||||
also runs the equivalent stock-0.37 control (a pinned minimal adapter where fork-only code does not compile) with all three
|
||||
source/lock identities; unavailable equivalence is an attribution limitation.
|
||||
|
||||
Predefined gates: investigate repeatable >5% median or >10% p95 layout/tessellation/interactive regression, >5% peak RSS,
|
||||
any new idle busy-loop/repeating repaint/absolute-budget failure; for sub-millisecond cases require >0.05 ms or corroborating
|
||||
instruction/allocation counts. Rerun noise; never widen tolerance after a failure. Intentional tradeoffs need documented
|
||||
review.
|
||||
|
||||
### 7.2 Targeted upstream probes
|
||||
|
||||
These are external reports, not reproduced here.
|
||||
|
||||
- **#8731 text tessellation:** compare unchanged 0.36.2, stock 0.37.0 and the selected fork on history/picker fixtures with
|
||||
`perf stat -e instructions:u` (Linux) and wall time. If the reported plain-text tessellation regression reproduces, do not
|
||||
accept it silently and do not add a clamp-only texture change. Evaluate a per-row fast path only with color-glyph rows
|
||||
covered; verify identical vertices/indices and pixel output for mixed/color rows. Textures/wrap/filter semantics stay
|
||||
unchanged.
|
||||
- **#8736 DX12 pipeline creation:** not measurable in this crate’s headless tests. When an adopting app uses eframe/wgpu on
|
||||
Windows DX12, time `Renderer::new` and first frame separately from build time; compare stock/fork and a shader fix that
|
||||
preserves Repeat/MirroredRepeat, not clamp-only.
|
||||
- **#8512 system-font startup:** measure separately only if a host enables `system_fonts`; preserve glyph coverage and
|
||||
compare configured providers; do not enable it in `ai-egui` defaults.
|
||||
- **#8763/PR #8764:** no selection-sensitive action exists here. If an app adds one, test click-away before/after the fix; do
|
||||
not patch egui in this repository; fork P7c owns the selected #8763 fix, PR #8764 is open/unmerged.
|
||||
- **#8748:** no viewport code here. Consumer apps test deferred child windows; stock 0.37.0 lacks the upstream
|
||||
closed/unmerged PR, and fork P7c owns the selected fix.
|
||||
|
||||
## 8. Risks, unresolved questions, and acceptance blockers
|
||||
|
||||
### 8.1 Blocking acceptance criteria
|
||||
|
||||
- `cargo fmt`, strict clippy, workspace tests, wasm checks for `ai-core`/`ai-features`, and `cargo-deny` pass on the exact
|
||||
candidate revision under managed jobs.
|
||||
- Every kittest input has an accessible name; no test disables the accessibility check.
|
||||
- Test/save/export/setup actions are exactly-once under repeated UI passes; queued StartTest disables admission immediately,
|
||||
a hidden `poll` drives a running test without a paint, endpoint generation/cancellation is covered, and concurrent
|
||||
`Saved`+`Closed` are both delivered.
|
||||
- Existing provenance, consent, setup-ticket, and shared-store semantics remain covered.
|
||||
- Pixel suite passes with the qualified kittest wgpu renderer/CPU adapter and recorded kittest/wgpu/driver identities;
|
||||
every changed golden is individually reviewed; no bulk refresh.
|
||||
- Performance baseline, candidate and every investigated regression record raw samples, environment details and the
|
||||
equivalent stock-0.37 control per §7; no regression is hidden by tolerance changes.
|
||||
- The production graph pins the approved storytold egui family and winit by immutable full SHAs, repeats patches at the
|
||||
consumer-smoke root, and produces one egui family/one winit in `cargo tree`. The smoke root proves one intended
|
||||
`craft-ai-core` identity.
|
||||
- Feature resolution is checked per root; the production graph does not silently enable `default_fonts`;
|
||||
tests/examples/consumer smoke each record their policy.
|
||||
- **Native platform gate:** Linux X11 and Wayland with recorded compositor identity, actual focus/clipboard/TextEdit/IME
|
||||
behavior, and screenshots; Windows in a uniquely owned clone with a visible edited/saved/reopened connection workflow and
|
||||
screenshots. macOS IME/Metal/termination is an explicit unresolved blocker, not a silent pass. Record exact commands,
|
||||
fixtures, screenshot paths and actual selected test counts. If the library intentionally defers native evidence, state that
|
||||
migration is not acceptance-complete for native use.
|
||||
- A fresh independent read-only review of the final test packet is complete. Native/platform and publication review remain
|
||||
open.
|
||||
- Explicit human publication approval is obtained for each push/new PR.
|
||||
|
||||
### 8.2 Unresolved questions
|
||||
|
||||
1. **No current app consumer.** Checkout evidence says library-only. The migration defines a consumer recipe and smoke
|
||||
workspace but invents no downstream acceptance; adoption gets its own PR, pin, font policy and tests.
|
||||
2. **Stable status ID.** `StatusView` has no role ID; its label is an ID salt. A later API may add a stable role id
|
||||
(breaking) or keep the documented English-label requirement. This migration does not change it.
|
||||
3. **Font ownership.** Each adopting app must supply fonts or enable eframe `default_fonts`; a custom host font lacking
|
||||
bullet/ellipsis/em dash/middle dot needs its own glyph test.
|
||||
4. **#8731.** If upstream/fork text tessellation is materially slower, a reviewed fork fix may be required before rollout; a
|
||||
documented exception for a text-light widget set needs explicit review. Measure first.
|
||||
5. **Fork timing/SHAs.** Approved integrated storytold egui/winit SHAs do not exist yet. AI-2 is blocked until publication;
|
||||
SHAs are recorded, never invented. aiongg candidates remain research inputs.
|
||||
6. **Duplicate core.** One authoritative `craft-ai-core` is future `CORE-SYNC` work, not part of this migration. Until then
|
||||
the smoke root stays on one identity and apps must not combine both core packages.
|
||||
7. **Native evidence.** macOS IME/Metal, physical Windows GPU, Linux compositor/PRIMARY and screen-reader behavior remain
|
||||
environment gates; no planning shell has a display session.
|
||||
|
||||
### 8.3 Known limitations of this plan
|
||||
|
||||
- The original source audit ran no compile or test; API impact there was inferred from 0.37.0 source/changelog. The final
|
||||
headless follow-up qualification covers 108 tests, five candidate snapshots, the `eg` lifecycle mutants and the locked
|
||||
consumer smoke; it does not cover native workflows, physical GPUs, performance or the other mutation groups.
|
||||
- The consumer table reflects the exact planning checkouts; re-inventory before final integration.
|
||||
- The fork/winit candidates are not accepted storytold revisions; E1–E21, W15P, E4/P2b and E21b/P7b are not revalidated here.
|
||||
Upstream #8731, #8736, #8512, #8763, #8764 and #8748 are external reports.
|
||||
- No native screenshot, IME, clipboard, PRIMARY, accessibility-tree, GPU or performance result is claimed. `docs/LAYOUT.md`
|
||||
no longer claims PhotoCraft or launcher path dependencies; the planning checkouts did not reproduce one.
|
||||
|
||||
## 9. Publication and review
|
||||
|
||||
This document is a local planning draft. Do not push it, open a PR, comment on upstream issues, merge, release, or send it to
|
||||
another chat. Before any publication: show the exact branch/commit range, files, and validation commands/results; obtain
|
||||
explicit human approval for that publication; keep the plan and later implementation reviews in the dedicated evidence
|
||||
directory; preserve the old coherent dependency set for rollback; add no agent/model/tool attribution; and keep contributor
|
||||
metadata username-only (`@skylartaylor`) without altering other contributors.
|
||||
|
||||
|
||||
## 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/craft-ai-integrations` `main` now resolves to `1759a52660b138f8cae7ba848774a49b7572b0ab`. 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.
|
||||
|
||||
craft-ai-native is now a normal ai-egui dependency. Preserve the one-button setup UI, UseSuggestedFolder action, accessible blocker labels, route advice and new first-run/one-button tests.
|
||||
|
||||
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.
|
||||
|
||||
### Current dependency state: reviewed storytold pins, not acceptance
|
||||
|
||||
The checkout now resolves the reviewed storytold egui family at
|
||||
`6d518fc799681571963842f95b55a6b68431b15a` and winit at
|
||||
`a517b3846ee8457d84ee4975110bfda6797c44dc`. The root `[patch.crates-io]` supplies the family and
|
||||
native event bridge, and `consumer-smoke/` repeats that recipe because patches do not propagate to
|
||||
a consumer root. The root lockfile SHA-256 is recorded above.
|
||||
|
||||
This dependency state satisfies the pin half of AI-2. The follow-up source and headless tests are
|
||||
now qualified as recorded above: 108 tests, five candidate snapshots, the `eg` lifecycle mutant group
|
||||
and the locked consumer smoke. Native/platform and performance evidence remain absent, the other
|
||||
mutation groups were not rerun, and the repository has no published app adoption. No
|
||||
acceptance-complete claim is made; publication still needs explicit human approval.
|
||||
|
||||
## Human-review readiness summary — 2026-10-24
|
||||
|
||||
The documentation now records only the qualification actually run for this follow-up: 108 passing
|
||||
tests, strict all-target clippy and fmt, five hash-stable candidate snapshots, both compiled
|
||||
connection-panel lifecycle mutants caught, the negative pixel control caught at 75,486 changed
|
||||
pixels, restored source and goldens, a passing locked consumer smoke with one core identity, and a
|
||||
passing independent test-packet review.
|
||||
|
||||
Genuinely open AI gates: native Linux X11/Wayland and Windows focus/keyboard/clipboard/IME/rendering
|
||||
screenshots; macOS IME/Metal/termination; physical-GPU qualification; the performance protocol; any
|
||||
app adoption; the migration's other mutation groups; CORE-SYNC; and explicit human publication
|
||||
approval. This checkout remains a review draft, not a merge or release.
|
||||
@@ -3,7 +3,8 @@
|
||||
Each case breaks one safety rule in the source, runs the one test that should notice, and puts the
|
||||
source back. A case is **caught** only if the mutated code compiled and the test run failed. A mutant
|
||||
that does not compile counts as not caught, so every survivor is either a test gap (fix the test) or a
|
||||
bad mutant (fix the mutant). Cargo runs serially, targeted with `-p`, with `-j 8`.
|
||||
bad mutant (fix the mutant). Cargo runs serially, targeted with `-p` and `--locked`,
|
||||
honouring `CARGO_BUILD_JOBS` from the build resource.
|
||||
|
||||
| Script | Groups |
|
||||
|---|---|
|
||||
@@ -23,6 +24,7 @@ bad mutant (fix the mutant). Cargo runs serially, targeted with `-p`, with `-j 8
|
||||
| `mutation_check.py ps` | Node pack status report (`pack/status.rs`): links, hidden folders, names, the install record. |
|
||||
| `mutation_check.py vx` | Vertex express adapter (`ai-vertex/src/lib.rs`): the API key travels in the `x-goog-api-key` header, never in the request URL. |
|
||||
| `mutation_check.py lg` | Remembered licences (`registry/licences.rs`, `install.rs`): terms, versions, local files, damaged record. |
|
||||
| `mutation_check.py eg` | Connection panel lifecycle (`ai-egui/src/panel.rs`): the same-frame Save dedup and the stale-answer generation guard. Cases in `mutation_egui.py`; receipt and logs in `verification/eg-mutations.json`. |
|
||||
| `sam3_mutation_check.py` | `sg1 sg2 sg2b sg2c sg3` (SAM 3 masks, sessions, slots, cancel). |
|
||||
|
||||
```sh
|
||||
|
||||
@@ -4,7 +4,7 @@ after `sam3_mutation_check.py`.
|
||||
Each case breaks one safety rule in the source, runs the one test that should notice, and puts the
|
||||
source back. A case is CAUGHT only if the mutated code compiled and the test run failed; a case that
|
||||
does not compile counts as NOT CAUGHT (a bad mutant), so every survivor is either a test gap or a
|
||||
mutant to fix. Cargo runs serially, targeted with -p, with -j 8.
|
||||
mutant to fix. Cargo runs serially, targeted with -p, honouring CARGO_BUILD_JOBS.
|
||||
|
||||
Usage: mutation_check.py <group> [name-substring]. The groups are the keys of `GROUPS` below and are
|
||||
described in README.md (for example `rt` for the rten engine and `seg` for the selection contract);
|
||||
@@ -484,6 +484,7 @@ from mutation_native import NF # noqa: E402
|
||||
from mutation_native_setup import NS # noqa: E402
|
||||
from mutation_native_session import NQ # noqa: E402
|
||||
from mutation_native_widget import NW # noqa: E402
|
||||
from mutation_egui import EG_CASES # noqa: E402
|
||||
from mutation_rten import RTEN_CASES # noqa: E402
|
||||
from mutation_generic import GS_CASES # noqa: E402
|
||||
from mutation_generic_mask import GM_CASES # noqa: E402
|
||||
@@ -553,7 +554,7 @@ S21 = [
|
||||
('selections-share-a-sink-name', SAM21, 'NEXT_SELECTION.fetch_add(1, Ordering::Relaxed)', '0', CU, 'sam21_session', 'its_own_sink_name'),
|
||||
]
|
||||
|
||||
GROUPS = {'fr': FR, 'be': BE, 'bp': BP, 'rt': RTEN_CASES, 'gu': GU_CASES, 'gm': GM_CASES, 'gs': GS_CASES, 'seg': SEG_CASES, 'nw': NW, 'nq': NQ, 'ns': NS, 'nf': NF, 'ce': CE, 'cg': CG, 'sf': SF, 's21': S21, 'ca': CA, 'lg': LG, 'ps': PS, 'ct': CT, 'u2': U2, 'nr': NR, 'u1': U1, 'u3': U3, 'u4': U4, 'p4': P4, 'p10': P10, 'v1': V1, 'v2': V2, 'v3': V3, 'sd': SD, 'vx': VX, 'lc': LC, 'lr': LR, 'wf': WF}
|
||||
GROUPS = {'fr': FR, 'be': BE, 'bp': BP, 'rt': RTEN_CASES, 'gu': GU_CASES, 'gm': GM_CASES, 'gs': GS_CASES, 'seg': SEG_CASES, 'nw': NW, 'nq': NQ, 'ns': NS, 'nf': NF, 'ce': CE, 'cg': CG, 'sf': SF, 's21': S21, 'ca': CA, 'lg': LG, 'ps': PS, 'ct': CT, 'u2': U2, 'nr': NR, 'u1': U1, 'u3': U3, 'u4': U4, 'p4': P4, 'p10': P10, 'v1': V1, 'v2': V2, 'v3': V3, 'sd': SD, 'vx': VX, 'lc': LC, 'lr': LR, 'wf': WF, 'eg': EG_CASES}
|
||||
# Mutants whose effect is also refused by a second, independent check on this machine. They are
|
||||
# recorded as equivalent here, with the reason, and are never counted as caught.
|
||||
EQUIVALENT = {
|
||||
@@ -601,7 +602,7 @@ def main():
|
||||
path.write_bytes(text.replace(old, new).replace('\n', newline).encode('utf-8'))
|
||||
# target 'lib' runs the crate's own unit tests instead of an integration test file.
|
||||
where = ['--lib'] if target == 'lib' else ['--test', target]
|
||||
command = ['cargo', 'test', '-p', package, *where, test, '-j', '8', '--', '--nocapture']
|
||||
command = ['cargo', 'test', '--locked', '-p', package, *where, test, '--', '--nocapture']
|
||||
proc = subprocess.Popen(command, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
timed_out = False
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Deliberate-bug cases for group `eg`: the connection panel's lifecycle guards.
|
||||
|
||||
Loaded by mutation_check.py. Each case breaks one rule and names an existing ai-egui test that
|
||||
must fail. The runner restores the original file in its `finally` block and writes the diagnostic
|
||||
receipt to `tools/mutation/verification/eg-mutations.json` with per-case logs beside it.
|
||||
|
||||
Format: (name, file, old, new, package, test target, test filter).
|
||||
"""
|
||||
PANEL = 'crates/ai-egui/src/panel.rs'
|
||||
EG = 'craft-ai-egui'
|
||||
|
||||
EG_CASES = [
|
||||
# A forced second UI pass arrives after `poll` drained the queued Save. Without the frame
|
||||
# dedup, the same press queues another store write and reports Saved twice.
|
||||
('save-frame-dedup-removed', PANEL, 'if self.save_frame != Some(frame)\n && !self', 'if !self', EG, 'lib', 'panel::tests::a_save_consumed_by_poll_is_still_deduplicated_within_the_same_frame'),
|
||||
# The finish guard has two independent checks. Removing the generation half must still be
|
||||
# caught by the existing stale-answer unit test (same endpoint, older generation).
|
||||
('answer-generation-ignored', PANEL, 'if capture.generation == self.generation\n && capture.endpoint == self.form.draft.endpoint.trim()', 'if capture.endpoint == self.form.draft.endpoint.trim()', EG, 'lib', 'panel::tests::a_stale_answer_is_dropped_by_the_panel_guard'),
|
||||
]
|
||||
Reference in New Issue
Block a user