mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-11-02 18:07:04 +00:00
Migrate Craft Launcher to egui 0.37 (#873)
* Migrate Launcher to egui 0.37 and preserve current-main behavior * egui037: record final published pins and local qualification * fix(ui): gate CJK language drawing until fonts are installed * Document migrated launcher UI contracts and regression invariants
This commit is contained in:
committed by
GitHub
parent
932e718bbc
commit
4ab652afd8
@@ -22,6 +22,11 @@ next to them.
|
||||
| `assets/fonts/Inter-Variable.ttf` | Inter 4.001, variable (body text) | The Inter Project Authors (Rasmus Andersson) | Google Fonts, <https://github.com/google/fonts/tree/main/ofl/inter> (`Inter[opsz,wght].ttf`, renamed) | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/Archivo-Variable.ttf` | Archivo 2.001, variable (headings and app names) | The Archivo Project Authors (Omnibus-Type) | Google Fonts, <https://github.com/google/fonts/tree/main/ofl/archivo> (`Archivo[wdth,wght].ttf`, renamed) | SIL OFL 1.1, [`assets/fonts/OFL-Archivo.txt`](assets/fonts/OFL-Archivo.txt) |
|
||||
| `assets/fonts/GeistMono-Variable.ttf` | Geist Mono 1.701, variable (labels and buttons) | The Geist Project Authors (Vercel) | Google Fonts, <https://github.com/google/fonts/tree/main/ofl/geistmono> (`GeistMono[wght].ttf`, renamed) | SIL OFL 1.1, [`assets/fonts/OFL-GeistMono.txt`](assets/fonts/OFL-GeistMono.txt) |
|
||||
| (dependency) `epaint_default_fonts/fonts/NotoEmoji-Regular.ttf` | Noto Emoji Regular, monochrome emoji fallback for arbitrary release-note, feed and alert text | The Noto Project Authors (Google) | Bundled transitively by `egui 0.37`'s `monochrome_emoji_fonts` feature from `epaint_default_fonts`; <https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts> | SIL OFL 1.1, `epaint_default_fonts/fonts/OFL.txt`; see also the dependency `README` and egui `README.md` |
|
||||
| (dependency) `epaint_default_fonts/fonts/emoji-icon-font.ttf` | emoji-icon-font, monochrome symbol/emoji fallback | John Slegers | Bundled transitively by `egui 0.37`'s `monochrome_emoji_fonts` feature from `epaint_default_fonts`; <https://github.com/jslegers/emoji-icon-font> | MIT, Copyright (c) 2014 John Slegers, `epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt` |
|
||||
| (dependency) `epaint_default_fonts/fonts/egui-icons.ttf` | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts); included by default_fonts independently of monochrome_emoji_fonts | MIT, Copyright (c) 2014 John Slegers; full upstream text in `NOTICE` |
|
||||
| (dependency) `epaint_default_fonts/fonts/Ubuntu-Light.ttf` | Ubuntu Light, default font fallback | Canonical Ltd. | [Pinned egui source](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts) | Ubuntu Font Licence 1.0; full upstream text and copyright in `NOTICE` |
|
||||
| (dependency) `epaint_default_fonts/fonts/Hack-Regular.ttf` | Hack Regular, default monospace fallback | Source Foundry Authors; Bitstream Inc.; DejaVu contributors | [Pinned egui source](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts) | MIT and Bitstream Vera License; DejaVu work public domain; full upstream notices in `NOTICE` |
|
||||
|
||||
### Craft App Suite icons
|
||||
|
||||
|
||||
Generated
+201
-96
@@ -4,9 +4,9 @@ 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 = [
|
||||
"enumn",
|
||||
"serde",
|
||||
@@ -15,12 +15,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_atspi_common"
|
||||
version = "0.18.1"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
|
||||
checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.36.0",
|
||||
"accesskit_consumer",
|
||||
"atspi-common",
|
||||
"phf",
|
||||
"serde",
|
||||
@@ -29,43 +29,37 @@ dependencies = [
|
||||
|
||||
[[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]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.36.0"
|
||||
name = "accesskit_ios"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
|
||||
checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
"accesskit_consumer",
|
||||
"hashbrown 0.17.1",
|
||||
"objc2 0.5.3",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-ui-kit 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_macos"
|
||||
version = "0.26.3"
|
||||
version = "0.27.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
|
||||
checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.38.0",
|
||||
"hashbrown 0.16.1",
|
||||
"accesskit_consumer",
|
||||
"hashbrown 0.17.1",
|
||||
"objc2 0.5.3",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
@@ -73,9 +67,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_unix"
|
||||
version = "0.21.1"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
|
||||
checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_atspi_common",
|
||||
@@ -91,13 +85,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_windows"
|
||||
version = "0.32.1"
|
||||
version = "0.35.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
|
||||
checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.35.0",
|
||||
"hashbrown 0.16.1",
|
||||
"accesskit_consumer",
|
||||
"hashbrown 0.17.1",
|
||||
"static_assertions",
|
||||
"windows",
|
||||
"windows-core",
|
||||
@@ -105,11 +99,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_winit"
|
||||
version = "0.32.2"
|
||||
version = "0.34.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
|
||||
checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_ios",
|
||||
"accesskit_macos",
|
||||
"accesskit_unix",
|
||||
"accesskit_windows",
|
||||
@@ -259,6 +254,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -949,7 +945,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-quit"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"egui",
|
||||
@@ -1185,9 +1181,8 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[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",
|
||||
@@ -1196,9 +1191,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",
|
||||
@@ -1221,6 +1215,7 @@ dependencies = [
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"ron",
|
||||
"send_wrapper",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"wasm-bindgen",
|
||||
@@ -1233,9 +1228,8 @@ dependencies = [
|
||||
|
||||
[[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",
|
||||
@@ -1255,9 +1249,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",
|
||||
@@ -1275,32 +1268,35 @@ 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 = [
|
||||
"accesskit_winit",
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"image",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-ui-kit 0.3.2",
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
@@ -1314,9 +1310,8 @@ dependencies = [
|
||||
|
||||
[[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",
|
||||
@@ -1328,9 +1323,8 @@ 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",
|
||||
@@ -1355,9 +1349,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",
|
||||
"serde",
|
||||
@@ -1371,22 +1364,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "2.7.3"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "0.17.0"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1446,9 +1439,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",
|
||||
@@ -1465,16 +1457,17 @@ dependencies = [
|
||||
"serde",
|
||||
"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 = "equivalent"
|
||||
@@ -1580,6 +1573,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"
|
||||
@@ -1597,6 +1596,12 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1937,12 +1942,12 @@ 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",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -2139,6 +2144,15 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -2147,7 +2161,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2156,7 +2170,7 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2537,12 +2551,12 @@ 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 0.35.0",
|
||||
"accesskit_consumer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2887,6 +2901,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.18.2"
|
||||
@@ -3314,6 +3337,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 = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -3394,10 +3427,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
@@ -3406,9 +3450,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
@@ -3416,9 +3460,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
@@ -3429,9 +3473,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
@@ -4127,6 +4171,12 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "send_wrapper"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -4817,6 +4867,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "type-map"
|
||||
version = "0.5.1"
|
||||
@@ -4855,6 +4916,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -4867,6 +4934,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"
|
||||
@@ -4980,9 +5053,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",
|
||||
@@ -4995,9 +5068,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",
|
||||
@@ -5721,8 +5794,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",
|
||||
@@ -5819,6 +5891,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 = "writeable"
|
||||
version = "0.6.4"
|
||||
@@ -6214,3 +6304,18 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[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_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"
|
||||
|
||||
+23
-5
@@ -6,7 +6,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
version = "0.8.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/craft-launcher"
|
||||
|
||||
[workspace.lints.rust]
|
||||
@@ -20,10 +20,10 @@ todo = "warn"
|
||||
artcraft-launcher-core = { path = "crates/core" }
|
||||
artcraft-launcher-ui-egui = { path = "crates/ui-egui" }
|
||||
artcraft-launcher-native = { path = "crates/native" }
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
egui = { version = "0.37", features = ["monochrome_emoji_fonts"] }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
semver = { version = "1", features = ["serde"] }
|
||||
@@ -46,3 +46,21 @@ inherits = "release"
|
||||
opt-level = "s"
|
||||
lto = "fat"
|
||||
strip = "debuginfo"
|
||||
|
||||
# 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" }
|
||||
|
||||
@@ -24,3 +24,265 @@ Geist Mono font (assets/fonts/GeistMono-Variable.ttf) — SIL Open Font License
|
||||
Craft App Suite app icons (assets/app-icons/*.png) — from each app's repository
|
||||
(github.com/storytold/<app>), MIT OR Apache-2.0, Copyright (c) 2026 ArtCraft Team and the <App>
|
||||
contributors.
|
||||
|
||||
|
||||
egui icon font subset (dependency asset)
|
||||
----------------------------------------
|
||||
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
|
||||
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
|
||||
uses the same MIT license. Upstream license text follows unchanged.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
Noto Emoji font (dependency asset)
|
||||
----------------------------------
|
||||
epaint_default_fonts 0.37, pinned with egui 0.37 at
|
||||
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
|
||||
embeds NotoEmoji-Regular.ttf, the monochrome emoji fallback used when egui's
|
||||
`monochrome_emoji_fonts` feature is enabled. The font's copyright notice and the
|
||||
upstream SIL Open Font License 1.1 text follow unchanged.
|
||||
|
||||
Copyright 2013 Google Inc. All Rights Reserved.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
|
||||
Ubuntu-Light.ttf (dependency asset)
|
||||
----------------------------------------
|
||||
Included by epaint_default_fonts at egui revision
|
||||
6d518fc799681571963842f95b55a6b68431b15a.
|
||||
Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0
|
||||
|
||||
-------------------------------
|
||||
UBUNTU FONT LICENCE Version 1.0
|
||||
-------------------------------
|
||||
|
||||
PREAMBLE
|
||||
This licence allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely. The fonts, including any derivative works, can be
|
||||
bundled, embedded, and redistributed provided the terms of this licence
|
||||
are met. The fonts and derivatives, however, cannot be released under
|
||||
any other licence. The requirement for fonts to remain under this
|
||||
licence does not require any document created using the fonts or their
|
||||
derivatives to be published under this licence, as long as the primary
|
||||
purpose of the document is not to be a vehicle for the distribution of
|
||||
the fonts.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this licence and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as received under this licence.
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to
|
||||
a new environment.
|
||||
|
||||
"Copyright Holder(s)" refers to all individuals and companies who have a
|
||||
copyright ownership of the Font Software.
|
||||
|
||||
"Substantially Changed" refers to Modified Versions which can be easily
|
||||
identified as dissimilar to the Font Software by users of the Font
|
||||
Software comparing the Original Version with the Modified Version.
|
||||
|
||||
To "Propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification and with or without charging
|
||||
a redistribution fee), making available to the public, and in some
|
||||
countries other activities as well.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
This licence does not grant any rights under trademark law and all such
|
||||
rights are reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to propagate the Font Software, subject to
|
||||
the below conditions:
|
||||
|
||||
1) Each copy of the Font Software must contain the above copyright
|
||||
notice and this licence. These can be included either as stand-alone
|
||||
text files, human-readable headers or in the appropriate machine-
|
||||
readable metadata fields within text or binary files as long as those
|
||||
fields can be easily viewed by the user.
|
||||
|
||||
2) The font name complies with the following:
|
||||
(a) The Original Version must retain its name, unmodified.
|
||||
(b) Modified Versions which are Substantially Changed must be renamed to
|
||||
avoid use of the name of the Original Version or similar names entirely.
|
||||
(c) Modified Versions which are not Substantially Changed must be
|
||||
renamed to both (i) retain the name of the Original Version and (ii) add
|
||||
additional naming elements to distinguish the Modified Version from the
|
||||
Original Version. The name of such Modified Versions must be the name of
|
||||
the Original Version, with "derivative X" where X represents the name of
|
||||
the new work, appended to that name.
|
||||
|
||||
3) The name(s) of the Copyright Holder(s) and any contributor to the
|
||||
Font Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except (i) as required by this licence, (ii) to
|
||||
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
|
||||
their explicit written permission.
|
||||
|
||||
4) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this licence, and must not be distributed
|
||||
under any other licence. The requirement for fonts to remain under this
|
||||
licence does not affect any document created using the Font Software,
|
||||
except any version of the Font Software extracted from a document
|
||||
created using the Font Software may only be distributed under this
|
||||
licence.
|
||||
|
||||
TERMINATION
|
||||
This licence becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
|
||||
Hack-Regular.ttf (dependency asset)
|
||||
----------------------------------------
|
||||
Included by epaint_default_fonts at egui revision
|
||||
6d518fc799681571963842f95b55a6b68431b15a.
|
||||
|
||||
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
|
||||
|
||||
The work in the DejaVu project was committed to the public domain.
|
||||
|
||||
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Source Foundry Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
BITSTREAM VERA LICENSE
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
|
||||
|
||||
@@ -20,7 +20,7 @@ local-mirror = ["artcraft-launcher-native/local-mirror"]
|
||||
artcraft-launcher-core = { workspace = true }
|
||||
artcraft-launcher-ui-egui = { workspace = true }
|
||||
artcraft-launcher-native = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence", "links"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "clipboard", "wgpu", "accesskit", "wayland", "x11", "persistence", "links"] }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
|
||||
@@ -30,7 +30,7 @@ notify-rust = "4"
|
||||
# Cargo.lock through eframe's "persistence" feature.
|
||||
ron = "0.12.2"
|
||||
serde = { workspace = true }
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c", features = ["egui"] }
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["egui"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = "5"
|
||||
|
||||
@@ -127,10 +127,13 @@ pub(crate) fn redact_credentials(text: &str) -> String {
|
||||
let prefix_len = idx + 7;
|
||||
result.push_str(&remaining[..prefix_len]);
|
||||
let mut token_rest = &remaining[prefix_len..];
|
||||
let spaces = token_rest.chars().take_while(|c| c.is_whitespace() && *c != '\r' && *c != '\n').count();
|
||||
if spaces > 0 {
|
||||
result.push_str(&token_rest[..spaces]);
|
||||
token_rest = &token_rest[spaces..];
|
||||
// Stop at the first byte that is not ordinary horizontal whitespace. `find` returns a
|
||||
// byte index, so multi-byte Unicode whitespace (NBSP, em space, ideographic space) is
|
||||
// copied whole instead of being cut at a non-char boundary.
|
||||
let spaces_end = token_rest.find(|c: char| !(c.is_whitespace() && c != '\r' && c != '\n')).unwrap_or(token_rest.len());
|
||||
if spaces_end > 0 {
|
||||
result.push_str(&token_rest[..spaces_end]);
|
||||
token_rest = &token_rest[spaces_end..];
|
||||
}
|
||||
let end_token = token_rest.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '\r' || c == '\n').unwrap_or(token_rest.len());
|
||||
result.push_str("[REDACTED]");
|
||||
@@ -362,4 +365,13 @@ mod tests {
|
||||
assert_eq!(redact_credentials("header \"authorization: bearer my_secret\" and more"), "header \"authorization: bearer [REDACTED]\" and more");
|
||||
assert_eq!(redact_credentials("normal log line without tokens"), "normal log line without tokens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_credentials_copies_unicode_whitespace_whole() {
|
||||
// NBSP (U+00A0), em space (U+2003) and ideographic space (U+3000) are each multiple
|
||||
// UTF-8 bytes; a character count must not be used as a byte slice index.
|
||||
assert_eq!(redact_credentials("Authorization: Bearer \u{a0}secretToken"), "Authorization: Bearer \u{a0}[REDACTED]");
|
||||
assert_eq!(redact_credentials("Bearer \u{2003}\u{3000}tok\u{a0}tail"), "Bearer \u{2003}\u{3000}[REDACTED]\u{a0}tail");
|
||||
assert_eq!(redact_credentials("Bearer \u{a0}\u{a0}multi\u{2003}gap"), "Bearer \u{a0}\u{a0}[REDACTED]\u{2003}gap");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,12 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// The native window and renderer setup. `hidden` only sets the initial visibility; the shell
|
||||
/// shows the window after a tray start. Non-macOS windows are undecorated because the header
|
||||
/// draws the title bar, while macOS keeps its bundle icon and hides the system title. Window
|
||||
/// state persists to the portable-aware settings path, system font fallback is off so the
|
||||
/// deliberate bundled faces stay in charge, and wgpu limits are clamped to the adapter so a
|
||||
/// short adapter cannot fail device creation.
|
||||
fn native_options(hidden: bool) -> eframe::NativeOptions {
|
||||
let icon =
|
||||
eframe::icon_data::from_png_bytes(include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.artcraft-launcher.png")).ok();
|
||||
@@ -185,6 +191,11 @@ fn native_options(hidden: bool) -> eframe::NativeOptions {
|
||||
persist_window: true,
|
||||
persistence_path: settings_file::path(APP_ID),
|
||||
wgpu_options: within_adapter_limits(wgpu_options()),
|
||||
// The launcher's typography is deliberate (its bundled Latin and monochrome emoji faces
|
||||
// plus, for a CJK language, the one system face read by `theme`). eframe's system-font
|
||||
// provider would enumerate and select fonts asynchronously, changing which face draws Han
|
||||
// text and paying startup cost.
|
||||
system_font_fallback: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,23 @@
|
||||
//! Launchers from before the handshake send a bare `show`, which is still understood. The worst
|
||||
//! another local program can do with any of this is bring the window forward or make the launcher
|
||||
//! quit.
|
||||
//!
|
||||
//! ## Upgrading over the published 0.7.0 build
|
||||
//!
|
||||
//! Replacement of a running 0.7.0 instance remains an unqualified compatibility gap. The
|
||||
//! published 0.7.0 `answer` expects `hello <version> <token>` and grants `Replace` only when the
|
||||
//! second word equals its owner-only token file. A newer build sends `hello <version> <nonce>`
|
||||
//! instead, so 0.7.0 reads the nonce as a wrong token, replies `running 0.7.0`, and merely shows
|
||||
//! its window. If that reply arrives in time, a newer newcomer requests takeover but the old
|
||||
//! process does not release its port; a same-version newcomer defers. The old reader and new
|
||||
//! client have similar timeouts, so a delayed reply can instead reach the legacy fallback and
|
||||
//! leave another process running without the listening socket. These timing paths need an
|
||||
//! old-release interoperability test. Quit the running 0.7.0 launcher from its tray menu before
|
||||
//! starting the new version. Do not make the new side
|
||||
//! send the token to force a `Replace`: that would restore the clear-text shared-secret handshake
|
||||
//! this protocol removed (0.7.0 itself already puts the token on the wire when it starts). A
|
||||
//! future compatibility shim would need an out-of-band, authenticated way to stop the old process;
|
||||
//! it must not accept an unauthenticated lifecycle request.
|
||||
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
|
||||
|
||||
@@ -32,6 +32,10 @@ js-sys = "0.3"
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
# egui 0.37 no longer bundles its fallback faces by default; these tests install them
|
||||
# explicitly. Production also inherits `default_fonts` from eframe and the workspace's
|
||||
# `monochrome_emoji_fonts` feature.
|
||||
egui = { workspace = true, features = ["default_fonts"] }
|
||||
serde_json = { workspace = true }
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
+523
-48
@@ -75,6 +75,31 @@ pub(crate) struct Toast {
|
||||
pub until: f64,
|
||||
}
|
||||
|
||||
/// One user action collected while a UI pass draws, sent once when the pass finishes.
|
||||
///
|
||||
/// egui 0.37 may run `App::ui` (and therefore `LauncherUi::ui`) several times for one visible
|
||||
/// frame after `request_discard`. Keeping the side effect out of the widget handler and comparing
|
||||
/// it with what this frame already sent makes the same click idempotent across those passes, while
|
||||
/// a new click in the next frame is still delivered.
|
||||
///
|
||||
/// The payload is not a widget identity: two different controls in one pass may legitimately ask
|
||||
/// for the same command (for example the update button on a card and the launcher-update banner).
|
||||
/// The queue is drained in draw order and equal actions are counted, so a repeated pass can only
|
||||
/// repeat the actions it already produced, never collapse two distinct intents into one.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum UiAction {
|
||||
Command(Command),
|
||||
/// [`Command::Launch`] plus one launch-count increment.
|
||||
Launch(String),
|
||||
/// Re-run the manual update check, clearing the auto-update back-off.
|
||||
CheckNow,
|
||||
/// A message a UI interaction asked to show.
|
||||
Toast {
|
||||
text: String,
|
||||
error: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Dialog {
|
||||
Notes(String),
|
||||
@@ -157,6 +182,15 @@ pub struct LauncherUi {
|
||||
pub(crate) auto_update_attempted: std::collections::BTreeSet<(String, String)>,
|
||||
/// The URL or deep link to open when Discord is clicked.
|
||||
pub(crate) discord_url: &'static str,
|
||||
/// UI actions drawn in this pass, not sent yet.
|
||||
pending: Vec<UiAction>,
|
||||
/// The `egui::Context::cumulative_frame_nr` those actions belong to.
|
||||
frame: Option<u64>,
|
||||
/// UI actions this visible frame has already sent, so a discarded pass cannot repeat them.
|
||||
flushed: Vec<UiAction>,
|
||||
/// Toasts created from logic (which may run with an old input time) or the UI. Their expiry is
|
||||
/// fixed when the next `ui` pass actually shows them.
|
||||
pending_toasts: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
/// The language to draw in: the preference, or English where it needs a CJK font the system
|
||||
@@ -167,6 +201,14 @@ pub(crate) fn effective_lang(pref: &str) -> crate::i18n::Lang {
|
||||
if lang.needs_cjk_font() && !theme::cjk_font_available(lang) { crate::i18n::Lang::EN } else { lang }
|
||||
}
|
||||
|
||||
/// The language this frame may draw: a CJK preference only once its own face is installed in the
|
||||
/// context, never merely because the system has one. Callers draw with the result, so a language
|
||||
/// whose font becomes active only next pass cannot ask this pass for a missing glyph.
|
||||
pub(crate) fn drawable_lang(pref: &str, installed: Option<crate::i18n::Lang>) -> crate::i18n::Lang {
|
||||
let lang = crate::i18n::Lang::from_pref(pref);
|
||||
if lang.needs_cjk_font() && installed != Some(lang) { crate::i18n::Lang::EN } else { lang }
|
||||
}
|
||||
|
||||
/// How often to look again at what's installed, to notice apps installed or removed outside
|
||||
/// the launcher. A detection pass lists packages and reads a few files: cheap, but not free.
|
||||
const RESCAN_EVERY: i64 = 5 * 60;
|
||||
@@ -174,6 +216,12 @@ const RESCAN_EVERY: i64 = 5 * 60;
|
||||
const RESCAN_ON_FOCUS_AFTER: i64 = 30;
|
||||
|
||||
impl LauncherUi {
|
||||
/// Builds the UI shell around `backend`: installs the bundled fonts and an available system
|
||||
/// CJK face when the effective language needs it, plus image loaders; snapshots capabilities and
|
||||
/// platform, and migrates the settings in memory. It does not save the migrated settings or
|
||||
/// send `Configure`; [`Self::logic`] sends the configuration and the shell owns persistence.
|
||||
/// `fonts_ready` starts false, so the first `ui` pass is a gate that draws nothing and flushes
|
||||
/// any action queued before it.
|
||||
pub fn new(ctx: &egui::Context, backend: Box<dyn Backend>, settings: Settings) -> Self {
|
||||
let lang = effective_lang(&settings.language);
|
||||
let cjk_font = lang.needs_cjk_font().then_some(lang);
|
||||
@@ -218,6 +266,10 @@ impl LauncherUi {
|
||||
was_focused: true,
|
||||
auto_update_attempted: std::collections::BTreeSet::new(),
|
||||
discord_url: artcraft_launcher_core::catalog::DISCORD_URL,
|
||||
pending: Vec::new(),
|
||||
frame: None,
|
||||
flushed: Vec::new(),
|
||||
pending_toasts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +284,10 @@ impl LauncherUi {
|
||||
self.applied_motion = None;
|
||||
self.motion_recheck = 0.0;
|
||||
self.toasts.clear();
|
||||
// Actions belong to the old context's frame; messages waiting to be shown do not.
|
||||
self.pending.clear();
|
||||
self.frame = None;
|
||||
self.flushed.clear();
|
||||
}
|
||||
|
||||
/// Input on its way to egui (the shell's `raw_input_hook`): with fewer animations asked for,
|
||||
@@ -266,14 +322,91 @@ impl LauncherUi {
|
||||
self.backend.send(command);
|
||||
}
|
||||
|
||||
pub(crate) fn toast(&mut self, ctx: &egui::Context, text: impl Into<String>, error: bool) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
self.toasts.push(Toast { text: text.into(), error, until: now + if error { 8.0 } else { 4.5 } });
|
||||
if self.toasts.len() > 4 {
|
||||
self.toasts.remove(0);
|
||||
/// The toolbar's check button. The side effects run once, when the pass flushes.
|
||||
pub(crate) fn check_now_from_ui(&mut self) {
|
||||
self.act(UiAction::CheckNow);
|
||||
}
|
||||
|
||||
/// Queues one user action. Distinct controls may queue equal payloads in the same pass; the
|
||||
/// per-frame duplicate check happens in [`Self::flush_actions`] when the draw order is known.
|
||||
fn act(&mut self, action: UiAction) {
|
||||
self.pending.push(action);
|
||||
}
|
||||
|
||||
/// Queues a backend command at this pass's action boundary. [`Self::flush_actions`] delivers it
|
||||
/// once per visible frame; equal commands queued by distinct controls in one pass are separate
|
||||
/// intents and are both kept.
|
||||
pub(crate) fn act_command(&mut self, command: Command) {
|
||||
self.act(UiAction::Command(command));
|
||||
}
|
||||
|
||||
/// Starts a visible frame: only a completed `run_ui` advances the frame number, so all discarded
|
||||
/// passes of one frame share a `flushed` list.
|
||||
fn begin_ui_frame(&mut self, ctx: &egui::Context) {
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
if self.frame != Some(frame) {
|
||||
self.frame = Some(frame);
|
||||
self.flushed.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the actions collected by this pass, exactly once per visible frame.
|
||||
///
|
||||
/// Actions are compared by their `rank` among equal actions in this pass: the first Install in
|
||||
/// a pass is the first Install that frame, the second is the second. A re-run pass produces the
|
||||
/// same ranks in the same draw order, so anything at or below the number already sent is a
|
||||
/// repeat; a genuinely new second intent of the same kind still gets its own rank and is sent.
|
||||
fn flush_actions(&mut self) {
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
for (index, action) in pending.iter().enumerate() {
|
||||
let rank = pending[..index].iter().filter(|earlier| *earlier == action).count();
|
||||
let already_sent = self.flushed.iter().filter(|sent| *sent == action).count();
|
||||
if rank < already_sent {
|
||||
// This action belongs to an earlier pass of the same visible frame.
|
||||
continue;
|
||||
}
|
||||
match action {
|
||||
UiAction::Command(command) => self.backend.send(command.clone()),
|
||||
UiAction::Launch(id) => {
|
||||
let opened = self.settings.launches.entry(id.clone()).or_insert(0);
|
||||
*opened = opened.saturating_add(1);
|
||||
self.backend.send(Command::Launch { app: id.clone() });
|
||||
}
|
||||
UiAction::CheckNow => {
|
||||
self.auto_update_attempted.clear();
|
||||
self.backend.send(Command::CheckForUpdates { force: true });
|
||||
self.backend.send(Command::Detect);
|
||||
self.last_detect = self.backend.now();
|
||||
}
|
||||
UiAction::Toast { text, error } => self.queue_toast(text.clone(), *error),
|
||||
}
|
||||
self.flushed.push(action.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues a message for the next pass that can actually draw. Its age is deliberately not
|
||||
/// part of the queue: a job that finished while the window was hidden is still news when the
|
||||
/// window first opens, so the countdown starts on display (see [`Self::draw_toasts`]).
|
||||
fn queue_toast(&mut self, text: String, error: bool) {
|
||||
self.pending_toasts.push((text, error));
|
||||
if self.pending_toasts.len() > 4 {
|
||||
self.pending_toasts.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues a programmatic message made outside the UI action boundary (logic, tray work). It is
|
||||
/// shown by the next pass that can draw, and its expiry starts then; a UI interaction must use
|
||||
/// [`Self::toast_from_ui`] so a discarded pass cannot queue it twice.
|
||||
pub(crate) fn toast(&mut self, text: impl Into<String>, error: bool) {
|
||||
self.queue_toast(text.into(), error);
|
||||
}
|
||||
|
||||
/// A toast caused by a UI interaction: guarded by the same per-frame action boundary as
|
||||
/// installs and launches, so a discarded pass cannot show it twice.
|
||||
pub(crate) fn toast_from_ui(&mut self, text: impl Into<String>, error: bool) {
|
||||
self.act(UiAction::Toast { text: text.into(), error });
|
||||
}
|
||||
|
||||
pub(crate) fn entry(&self, id: &str) -> Option<&AppEntry> {
|
||||
self.entries.iter().find(|e| e.app.id == id)
|
||||
}
|
||||
@@ -302,9 +435,12 @@ impl LauncherUi {
|
||||
&& !crate::settings::is_flatpak_sandbox()
|
||||
}
|
||||
|
||||
/// Asks to install `id`. On a host that supports custom folders and for an app that is not
|
||||
/// installed, it opens the folder dialog and sends nothing until that is confirmed; otherwise
|
||||
/// it queues `Install` immediately, including for an already installed app (update/reinstall).
|
||||
pub(crate) fn install_app(&mut self, id: &str) {
|
||||
if !self.custom_install_folders() || self.entry(id).is_some_and(|e| e.installed.is_some()) {
|
||||
self.send(Command::Install { app: id.to_string() });
|
||||
self.act_command(Command::Install { app: id.to_string() });
|
||||
return;
|
||||
}
|
||||
self.dialog =
|
||||
@@ -313,7 +449,9 @@ impl LauncherUi {
|
||||
|
||||
/// Non-drawing work: runs every frame and also while the window is hidden in the tray.
|
||||
pub fn logic(&mut self, ctx: &egui::Context, host: &mut dyn Host) {
|
||||
set_current(crate::i18n::Lang::from_pref(&self.settings.language));
|
||||
// Messages made here (toasts, alerts) are drawn later, so use the language the UI can
|
||||
// actually show: English where the system has no CJK face for the preference.
|
||||
set_current(effective_lang(&self.settings.language));
|
||||
self.configure();
|
||||
if !self.started {
|
||||
self.started = true;
|
||||
@@ -339,7 +477,7 @@ impl LauncherUi {
|
||||
}
|
||||
let mut changed = false;
|
||||
for event in self.backend.poll() {
|
||||
changed |= self.handle(ctx, event);
|
||||
changed |= self.handle(event);
|
||||
}
|
||||
if changed {
|
||||
self.announce_updates(host);
|
||||
@@ -357,7 +495,7 @@ impl LauncherUi {
|
||||
}
|
||||
|
||||
/// Returns whether app state changed.
|
||||
fn handle(&mut self, ctx: &egui::Context, event: Event) -> bool {
|
||||
fn handle(&mut self, event: Event) -> bool {
|
||||
match event {
|
||||
Event::Latest { app, latest, from } => {
|
||||
self.on_latest(app, latest, from);
|
||||
@@ -404,7 +542,7 @@ impl LauncherUi {
|
||||
false
|
||||
}
|
||||
Event::JobFinished { app, result } => {
|
||||
self.on_job_finished(ctx, app, result);
|
||||
self.on_job_finished(app, result);
|
||||
true
|
||||
}
|
||||
Event::LauncherUpdate(r) => self.on_launcher_update(r),
|
||||
@@ -445,7 +583,7 @@ impl LauncherUi {
|
||||
}
|
||||
|
||||
/// Summarizes a job's end as a toast; cancellations have their own messages.
|
||||
fn on_job_finished(&mut self, ctx: &egui::Context, app: String, result: Result<String, String>) {
|
||||
fn on_job_finished(&mut self, app: String, result: Result<String, String>) {
|
||||
let launcher = app == artcraft_launcher_core::catalog::LAUNCHER_ID;
|
||||
// What the job was doing when it ended says what a cancellation called off.
|
||||
let last = if launcher { self.launcher_job.take() } else { self.entry(&app).and_then(|e| e.job) };
|
||||
@@ -454,23 +592,23 @@ impl LauncherUi {
|
||||
}
|
||||
let kept = self.entry(&app).is_some_and(|e| e.ready);
|
||||
match result {
|
||||
Ok(msg) => self.toast(ctx, msg, false),
|
||||
Ok(msg) => self.toast(msg, false),
|
||||
// The paused card says it all.
|
||||
Err(e) if e == PAUSED => {}
|
||||
Err(e) if e == CANCELLED && launcher => self.toast(ctx, tl!("Update cancelled"), false),
|
||||
Err(e) if e == CANCELLED && launcher => self.toast(tl!("Update cancelled"), false),
|
||||
// An install's own cancellations end as INSTALL_CANCELLED, so past the
|
||||
// download this is a removal called off while it waited for the installer.
|
||||
Err(e) if e == CANCELLED && matches!(last, Some(JobPhase::Waiting | JobPhase::Removing)) => {
|
||||
self.toast(ctx, tl!("Uninstall cancelled"), false);
|
||||
self.toast(tl!("Uninstall cancelled"), false);
|
||||
}
|
||||
Err(e) if e == CANCELLED => self.toast(ctx, tl!("Download cancelled"), false),
|
||||
Err(e) if e == CANCELLED => self.toast(tl!("Download cancelled"), false),
|
||||
Err(e) if e == INSTALL_CANCELLED && kept => {
|
||||
self.toast(ctx, tl!("Install cancelled. The download is kept for the next try."), false);
|
||||
self.toast(tl!("Install cancelled. The download is kept for the next try."), false);
|
||||
}
|
||||
Err(e) if e == INSTALL_CANCELLED => self.toast(ctx, tl!("Install cancelled"), false),
|
||||
Err(e) if e == INSTALL_CANCELLED => self.toast(tl!("Install cancelled"), false),
|
||||
// The Replace dialog says it all.
|
||||
Err(_) if matches!(&self.dialog, Some(Dialog::Replace { app: a, .. }) if *a == app) => {}
|
||||
Err(e) => self.toast(ctx, e, true),
|
||||
Err(e) => self.toast(e, true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,13 +731,13 @@ impl LauncherUi {
|
||||
/// Draws the launcher.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.begin_ui_frame(&ctx);
|
||||
if host.title_bar() == TitleBar::MacOs && ctx.input_mut(|i| i.consume_key(egui::Modifiers::MAC_CMD, egui::Key::M)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
|
||||
}
|
||||
// Set the current language for this frame based on settings.
|
||||
// `effective_lang` only picks a CJK language once the system's font has loaded; install
|
||||
// that face now, and draw nothing until the context has switched to it.
|
||||
let lang = effective_lang(&self.settings.language);
|
||||
set_current(lang);
|
||||
// `effective_lang` only picks a CJK language once its font has loaded.
|
||||
if lang.needs_cjk_font() && self.cjk_font != Some(lang) {
|
||||
theme::install_fonts(&ctx, Some(lang));
|
||||
self.cjk_font = Some(lang);
|
||||
@@ -608,8 +746,14 @@ impl LauncherUi {
|
||||
if !self.fonts_ready {
|
||||
self.fonts_ready = true;
|
||||
ctx.request_repaint();
|
||||
// A click cannot have produced an action on this pass (nothing was drawn), but a queued
|
||||
// action from an earlier pass must never be lost to the early return.
|
||||
self.flush_actions();
|
||||
return;
|
||||
}
|
||||
// Set the current language only after the font gate: a face installed by `set_fonts`
|
||||
// becomes active next pass, so drawing with it in the pass that installed it would panic.
|
||||
set_current(drawable_lang(&self.settings.language, self.cjk_font));
|
||||
self.zoom_input(&ctx);
|
||||
if self.applied_theme != Some(self.settings.theme) {
|
||||
theme::apply(&ctx, self.settings.theme);
|
||||
@@ -669,6 +813,7 @@ impl LauncherUi {
|
||||
});
|
||||
self.details_drawer(&ctx);
|
||||
self.dialogs(&ctx, host);
|
||||
self.flush_actions();
|
||||
self.draw_toasts(&ctx);
|
||||
if host.title_bar() == TitleBar::Custom {
|
||||
crate::widgets::window_edges(&ctx);
|
||||
@@ -740,9 +885,7 @@ impl LauncherUi {
|
||||
|
||||
/// Opens an app, and counts it for "Frequently used".
|
||||
pub(crate) fn launch_app(&mut self, id: &str) {
|
||||
let opened = self.settings.launches.entry(id.to_string()).or_insert(0);
|
||||
*opened = opened.saturating_add(1);
|
||||
self.send(Command::Launch { app: id.to_string() });
|
||||
self.act(UiAction::Launch(id.to_string()));
|
||||
}
|
||||
|
||||
/// Whether the app on screen under `id` is installed on this computer.
|
||||
@@ -790,8 +933,19 @@ impl LauncherUi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the toasts and dismisses a clicked one. Drains messages queued since the last drawing
|
||||
/// pass, starting each expiry on this pass, keeps at most four (dropping the oldest), removes
|
||||
/// expired ones, and asks for another frame while any remain; call it once per visible frame.
|
||||
fn draw_toasts(&mut self, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
// Logic may have queued these while no UI ran, with an old input time. They start their
|
||||
// countdown only now, on the first pass that can actually show them.
|
||||
for (text, error) in std::mem::take(&mut self.pending_toasts) {
|
||||
self.toasts.push(Toast { text, error, until: now + if error { 8.0 } else { 4.5 } });
|
||||
if self.toasts.len() > 4 {
|
||||
self.toasts.remove(0);
|
||||
}
|
||||
}
|
||||
self.toasts.retain(|t| t.until > now);
|
||||
if self.toasts.is_empty() {
|
||||
return;
|
||||
@@ -799,31 +953,352 @@ impl LauncherUi {
|
||||
ctx.request_repaint_after(Duration::from_millis(250));
|
||||
let t = Tokens::get(ctx);
|
||||
let anchor = egui::vec2(-20.0, -(crate::views::FOOTER_H + 12.0));
|
||||
egui::Area::new(egui::Id::new("toasts")).anchor(egui::Align2::RIGHT_BOTTOM, anchor).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
ui.set_max_width(380.0);
|
||||
let mut dismiss = None;
|
||||
for (i, toast) in self.toasts.iter().enumerate() {
|
||||
let accent = if toast.error { t.bad } else { t.good };
|
||||
let r = egui::Frame::new()
|
||||
.fill(t.bg)
|
||||
.stroke(egui::Stroke::new(1.0, t.line_strong))
|
||||
.shadow(theme::shadow(&t))
|
||||
.inner_margin(egui::Margin::symmetric(14, 10))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::ZERO, accent);
|
||||
ui.add(egui::Label::new(egui::RichText::new(crate::i18n::msg(&toast.text)).color(t.ink)).wrap());
|
||||
egui::Area::new(egui::Id::unique("toasts"))
|
||||
.role(egui::Role::Alert)
|
||||
.anchor(egui::Align2::RIGHT_BOTTOM, anchor)
|
||||
.order(egui::Order::Foreground)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_max_width(380.0);
|
||||
let mut dismiss = None;
|
||||
for (i, toast) in self.toasts.iter().enumerate() {
|
||||
let accent = if toast.error { t.bad } else { t.good };
|
||||
let r = egui::Frame::new()
|
||||
.fill(t.bg)
|
||||
.stroke(egui::Stroke::new(1.0, t.line_strong))
|
||||
.shadow(theme::shadow(&t))
|
||||
.inner_margin(egui::Margin::symmetric(14, 10))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::ZERO, accent);
|
||||
ui.add(egui::Label::new(egui::RichText::new(crate::i18n::msg(&toast.text)).color(t.ink)).wrap());
|
||||
});
|
||||
});
|
||||
});
|
||||
if r.response.interact(egui::Sense::click()).clicked() {
|
||||
dismiss = Some(i);
|
||||
if r.response.interact(egui::Sense::click()).clicked() {
|
||||
dismiss = Some(i);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
if let Some(i) = dismiss {
|
||||
self.toasts.remove(i);
|
||||
}
|
||||
});
|
||||
if let Some(i) = dismiss {
|
||||
self.toasts.remove(i);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use egui_kittest::Harness;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct NoHost;
|
||||
impl Host for NoHost {}
|
||||
|
||||
/// A backend that records commands and has no jobs, pictures or events.
|
||||
struct Recorder {
|
||||
sent: Rc<RefCell<Vec<Command>>>,
|
||||
}
|
||||
|
||||
impl Backend for Recorder {
|
||||
/// Records the command so the test can assert what crossed the UI boundary.
|
||||
fn send(&mut self, command: Command) {
|
||||
self.sent.borrow_mut().push(command);
|
||||
}
|
||||
/// Always empty: the recorder has no events, jobs or pictures.
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
Vec::new()
|
||||
}
|
||||
/// No install management, a neutral reveal label and no self-update, so UI paths needing them
|
||||
/// stay off.
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities { manage_apps: false, reveal_label: "Show", self_update: false }
|
||||
}
|
||||
/// Reports no platform, so the UI offers no OS-specific package format.
|
||||
fn platform(&self) -> Option<Platform> {
|
||||
None
|
||||
}
|
||||
/// A fixed zero clock; tests that need time drive their own backend.
|
||||
fn now(&self) -> i64 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// A UI and the context its fonts, theme and image loaders were installed into.
|
||||
fn launcher() -> (LauncherUi, Rc<RefCell<Vec<Command>>>, egui::Context) {
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let backend = Recorder { sent: sent.clone() };
|
||||
// A fixed language keeps `effective_lang` away from the machine's locale.
|
||||
let settings = Settings { language: "en".into(), notify: false, ..Settings::default() };
|
||||
let ctx = egui::Context::default();
|
||||
(LauncherUi::new(&ctx, Box::new(backend), settings), sent, ctx)
|
||||
}
|
||||
|
||||
/// One `run_ui` pass at a chosen input time, exactly like the native shell's callback order.
|
||||
fn frame(launcher: &mut LauncherUi, ctx: &egui::Context, time: f64) {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1180.0, 900.0))),
|
||||
time: Some(time),
|
||||
..Default::default()
|
||||
};
|
||||
let mut output = ctx.run_ui(input, |ui| launcher.ui(ui, &mut NoHost));
|
||||
// This logic-only harness has no renderer to receive texture uploads.
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Counts the `Install` commands in `sent`, ignoring every other command.
|
||||
fn installs(sent: &RefCell<Vec<Command>>) -> usize {
|
||||
sent.borrow().iter().filter(|command| matches!(command, Command::Install { .. })).count()
|
||||
}
|
||||
|
||||
/// A CJK preference is drawable only while that language's own face is installed: having the
|
||||
/// system font cached, or another script's face installed, is not enough.
|
||||
#[test]
|
||||
fn a_cjk_language_is_drawn_only_with_its_own_installed_face() {
|
||||
use crate::i18n::Lang;
|
||||
assert_eq!(drawable_lang("zh-CN", None), Lang::EN);
|
||||
assert_eq!(drawable_lang("zh-CN", Some(Lang::ZH_CN)), Lang::ZH_CN);
|
||||
assert_eq!(drawable_lang("zh-CN", Some(Lang::JA)), Lang::EN);
|
||||
assert_eq!(drawable_lang("ja", Some(Lang::ZH_CN)), Lang::EN);
|
||||
assert_eq!(drawable_lang("ja", Some(Lang::JA)), Lang::JA);
|
||||
assert_eq!(drawable_lang("fr", Some(Lang::ZH_CN)), Lang::FR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Two controls queueing the same command in one pass are two gestures and must both reach the
|
||||
/// backend.
|
||||
fn two_distinct_intents_with_the_same_command_are_both_delivered() {
|
||||
let (mut launcher, sent, _ctx) = launcher();
|
||||
launcher.frame = Some(1);
|
||||
// Two different controls in one pass can ask for the same command (the card's Update and
|
||||
// the banner's Update, say). Their payload is equal, but they are not one gesture.
|
||||
launcher.act_command(Command::Install { app: "photocraft".into() });
|
||||
launcher.act_command(Command::Install { app: "photocraft".into() });
|
||||
launcher.flush_actions();
|
||||
assert_eq!(installs(&sent), 2, "both intents must reach the backend: {:?}", sent.borrow());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A later pass within one visible frame may add genuinely different actions without losing the
|
||||
/// earlier one.
|
||||
fn distinct_actions_in_a_later_pass_are_both_delivered() {
|
||||
let (mut launcher, sent, _ctx) = launcher();
|
||||
launcher.frame = Some(1);
|
||||
launcher.act_command(Command::Install { app: "photocraft".into() });
|
||||
launcher.flush_actions();
|
||||
// A second pass that finds two genuinely different actions must not lose either.
|
||||
launcher.act_command(Command::Install { app: "filmcraft".into() });
|
||||
launcher.act_command(Command::Cancel { app: "lightcraft".into() });
|
||||
launcher.flush_actions();
|
||||
assert_eq!(
|
||||
*sent.borrow(),
|
||||
[
|
||||
Command::Install { app: "photocraft".into() },
|
||||
Command::Install { app: "filmcraft".into() },
|
||||
Command::Cancel { app: "lightcraft".into() },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A discarded second pass over the same gesture sends nothing, while the next visible frame
|
||||
/// accepts it again.
|
||||
fn a_repeated_pass_sends_nothing_but_a_new_frame_sends_again() {
|
||||
let (mut launcher, sent, _ctx) = launcher();
|
||||
launcher.frame = Some(1);
|
||||
launcher.act_command(Command::Install { app: "photocraft".into() });
|
||||
launcher.flush_actions();
|
||||
// The discarded second pass redraws the same clicked widget.
|
||||
launcher.act_command(Command::Install { app: "photocraft".into() });
|
||||
launcher.flush_actions();
|
||||
assert_eq!(installs(&sent), 1, "a second pass over the same gesture must not repeat it: {:?}", sent.borrow());
|
||||
// The next visible frame is a new gesture.
|
||||
launcher.frame = Some(2);
|
||||
launcher.flushed.clear();
|
||||
launcher.act_command(Command::Install { app: "photocraft".into() });
|
||||
launcher.flush_actions();
|
||||
assert_eq!(installs(&sent), 2, "the next frame must accept the action again: {:?}", sent.borrow());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A launch increments the persisted counter and sends one `Launch` per visible frame, not per
|
||||
/// pass.
|
||||
fn a_launch_count_is_incremented_once_per_visible_frame() {
|
||||
let (mut launcher, sent, _ctx) = launcher();
|
||||
launcher.frame = Some(1);
|
||||
launcher.act(UiAction::Launch("photocraft".into()));
|
||||
launcher.flush_actions();
|
||||
launcher.act(UiAction::Launch("photocraft".into()));
|
||||
launcher.flush_actions();
|
||||
assert_eq!(launcher.settings.launches.get("photocraft"), Some(&1));
|
||||
let launches = sent.borrow().iter().filter(|command| matches!(command, Command::Launch { .. })).count();
|
||||
assert_eq!(launches, 1, "one gesture, one launch command: {:?}", sent.borrow());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The font-gate pass flushes an action queued before it exactly once, and the following pass
|
||||
/// does not repeat it.
|
||||
fn a_pending_action_survives_the_fonts_ready_early_return_exactly_once() {
|
||||
let (mut launcher, sent, ctx) = launcher();
|
||||
// `new` installed the fonts but the first pass cannot draw with them yet.
|
||||
assert!(!launcher.fonts_ready);
|
||||
launcher.act_command(Command::Install { app: "photocraft".into() });
|
||||
frame(&mut launcher, &ctx, 0.0);
|
||||
assert_eq!(installs(&sent), 1, "the early return must flush what was already pending: {:?}", sent.borrow());
|
||||
frame(&mut launcher, &ctx, 0.25);
|
||||
assert_eq!(installs(&sent), 1, "the following pass must not repeat it: {:?}", sent.borrow());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A toast raised through the UI is queued once even when the same visible frame is drawn twice.
|
||||
fn a_ui_toast_is_shown_once_across_passes() {
|
||||
let (mut launcher, _sent, _ctx) = launcher();
|
||||
launcher.frame = Some(1);
|
||||
launcher.toast_from_ui("Finished", false);
|
||||
launcher.flush_actions();
|
||||
launcher.toast_from_ui("Finished", false);
|
||||
launcher.flush_actions();
|
||||
assert_eq!(launcher.pending_toasts.len(), 1, "a repeated pass must not queue the toast twice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A toast made while no UI ran starts its expiry on the first pass that draws it, not at the
|
||||
/// older logic time.
|
||||
fn a_hidden_result_gets_its_expiry_from_the_pass_that_shows_it() {
|
||||
let (mut launcher, _sent, ctx) = launcher();
|
||||
// The fonts gate pass draws nothing and shows no toast.
|
||||
frame(&mut launcher, &ctx, 0.0);
|
||||
// A job finished while the window was hidden, when `logic`'s input time was old (0).
|
||||
launcher.toast("PhotoCraft updated", false);
|
||||
frame(&mut launcher, &ctx, 1000.0);
|
||||
let toast = launcher.toasts.first().expect("the first drawing pass shows the pending toast");
|
||||
assert_eq!(toast.until, 1004.5, "expiry must be relative to the drawing pass, not the old logic time");
|
||||
// Past the stale (0 + 4.5) deadline this would already be gone; it is still fresh at 1000.
|
||||
frame(&mut launcher, &ctx, 1004.6);
|
||||
assert!(launcher.toasts.is_empty(), "the toast goes at its real expiry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Moving to a new window preserves a queued toast that has not yet been shown.
|
||||
fn attaching_a_window_keeps_messages_still_waiting_to_be_shown() {
|
||||
let (mut launcher, _sent, ctx) = launcher();
|
||||
launcher.toast("PhotoCraft updated", false);
|
||||
launcher.attach(&ctx);
|
||||
assert_eq!(launcher.pending_toasts.len(), 1, "a message from logic is not tied to the old window");
|
||||
}
|
||||
|
||||
/// A UI driven through the real toolbar → Settings → language picker path, with the test-only
|
||||
/// font seam, so the selected frame and the single font-gate pass can be inspected exactly.
|
||||
fn cjk_picker_harness() -> Harness<'static, Option<LauncherUi>> {
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut backend = Some(Recorder { sent });
|
||||
let settings = Settings { language: "en".into(), notify: false, ..Settings::default() };
|
||||
Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(backend) = backend.take()
|
||||
{
|
||||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(backend), settings.clone()));
|
||||
}
|
||||
if let Some(launcher) = state.as_mut() {
|
||||
launcher.logic(&ui.ctx().clone(), &mut NoHost);
|
||||
// The strict policy covers drawing passes only: translation is what asks the
|
||||
// fonts for glyphs. Logic may set the current language ahead of the gate, but
|
||||
// it draws nothing.
|
||||
let _strict = crate::i18n::StrictCjkDrawing::on(launcher.cjk_font);
|
||||
launcher.ui(ui, &mut NoHost);
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Opens Settings and its language combo through visible labels, leaving the picker open for a
|
||||
/// selection.
|
||||
fn open_language_picker(h: &mut Harness<'static, Option<LauncherUi>>) {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
h.get_by_label("Settings").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_value("English").click();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Clicks Simplified Chinese and checks the very frame that records it: the frame must still
|
||||
/// draw English and must not have installed a CJK face yet.
|
||||
fn select_chinese_in_its_settings_frame(h: &mut Harness<'static, Option<LauncherUi>>) {
|
||||
use crate::i18n::Lang;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
h.get_by_label("Simplified Chinese").click_accesskit();
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
let launcher = h.state().as_ref().expect("harness state");
|
||||
if launcher.settings.language == "zh-CN" {
|
||||
assert_eq!(crate::i18n::current(), Lang::EN, "the selection frame must still draw English");
|
||||
assert_eq!(launcher.cjk_font, None, "the selection frame must not install a face yet");
|
||||
assert!(!crate::i18n::drew_cjk(), "the selection frame must not draw CJK text");
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("the picker click must select Simplified Chinese");
|
||||
}
|
||||
|
||||
/// Selecting Simplified Chinese must keep the selection frame itself in English: its face is
|
||||
/// installed at the next pass's font gate, and drawing the chosen language before then asks
|
||||
/// the bundled (CJK-free) fonts for Chinese glyphs. The old `effective_lang` selector sets the
|
||||
/// current language to Chinese inside this frame and fails the strict policy.
|
||||
#[test]
|
||||
fn selecting_chinese_keeps_the_selection_frame_english_until_the_font_gate() {
|
||||
use crate::i18n::Lang;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
// Available: the system "has" a Chinese face. The seam's face is the real bundled Inter,
|
||||
// which has no CJK glyphs, so the gate can install a face without faking coverage.
|
||||
{
|
||||
let _fonts = crate::theme::CjkFontOverride::available(Lang::ZH_CN);
|
||||
let mut h = cjk_picker_harness();
|
||||
h.run_steps(8);
|
||||
open_language_picker(&mut h);
|
||||
select_chinese_in_its_settings_frame(&mut h);
|
||||
let marker = crate::theme::cjk_font(Lang::ZH_CN).expect("the seam answers with the bundled face");
|
||||
let inter: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
|
||||
assert_eq!(marker.bytes(), inter, "the available marker must be the real Inter face, not a CJK face");
|
||||
|
||||
// Exactly one gate pass: it installs the face and draws nothing. We stop here and do
|
||||
// not run the following pass, which would panic on missing Chinese glyphs in the Inter stand-in.
|
||||
h.step();
|
||||
{
|
||||
let launcher = h.state().as_ref().expect("harness state");
|
||||
assert_eq!(launcher.cjk_font, Some(Lang::ZH_CN), "the gate must install the face");
|
||||
}
|
||||
assert!(!crate::i18n::drew_cjk(), "the gate pass must not draw CJK text");
|
||||
}
|
||||
|
||||
// Unavailable: no face at all, so every frame stays English and the gate installs nothing.
|
||||
{
|
||||
let _fonts = crate::theme::CjkFontOverride::unavailable(Lang::ZH_CN);
|
||||
let mut h = cjk_picker_harness();
|
||||
h.run_steps(8);
|
||||
open_language_picker(&mut h);
|
||||
select_chinese_in_its_settings_frame(&mut h);
|
||||
h.run_steps(3);
|
||||
{
|
||||
let launcher = h.state().as_ref().expect("harness state");
|
||||
assert_eq!(crate::i18n::current(), Lang::EN, "without a face, frames stay English");
|
||||
assert_eq!(launcher.cjk_font, None, "without a face, the gate installs nothing");
|
||||
}
|
||||
h.get_by_label_contains("No font for this language was found");
|
||||
}
|
||||
}
|
||||
|
||||
/// The strict policy the regression relies on really rejects CJK text without its face.
|
||||
#[test]
|
||||
#[should_panic(expected = "strict CJK drawing policy")]
|
||||
fn strict_drawing_policy_rejects_cjk_text_without_its_installed_face() {
|
||||
use crate::i18n::Lang;
|
||||
let _guard = crate::i18n::StrictCjkDrawing::on(None);
|
||||
let _ = crate::i18n::tr(Lang::ZH_CN, "Settings");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn view_toggle(ui: &mut Ui, view: &mut ViewMode, tips: [&str; 2]) {
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, tip));
|
||||
if resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
|
||||
*view = mode;
|
||||
}
|
||||
@@ -75,15 +75,9 @@ pub fn refresh_button(ui: &mut Ui, tip: &str, busy: bool) -> egui::Response {
|
||||
if busy {
|
||||
egui::Spinner::new().size(16.0).color(t.muted).paint_at(ui, Rect::from_center_size(rect.center(), vec2(16.0, 16.0)));
|
||||
} else {
|
||||
p.text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"\u{27f3}",
|
||||
egui::FontId::proportional(17.0),
|
||||
if hovered { t.ink_strong } else { t.muted },
|
||||
);
|
||||
crate::widgets::paint_glyph(p, rect, crate::widgets::Glyph::Refresh, if hovered { t.ink_strong } else { t.muted });
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, !busy, tip));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, !busy, tip));
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if busy { resp } else { resp.on_hover_cursor(egui::CursorIcon::PointingHand) }
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@ mod tests {
|
||||
if let Some(font) = load(script) {
|
||||
let family = candidates(script).into_iter().find(|c| c.path.is_file()).map(|c| c.family);
|
||||
assert!(family.is_some());
|
||||
assert!(FontRef::from_index(&font.font, font.index).is_ok());
|
||||
assert!(FontRef::from_index(font.bytes(), font.index).is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ impl LauncherUi {
|
||||
}
|
||||
}
|
||||
if missing {
|
||||
self.send(Command::FetchScreenshots { app: details.id.to_string() });
|
||||
self.act_command(Command::FetchScreenshots { app: details.id.to_string() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ impl LauncherUi {
|
||||
// Asked every frame, even while closed, so the first opening starts from 0 and slides.
|
||||
let open = self.detail.is_some();
|
||||
let slide = if self.reduce_motion { 0.0 } else { SLIDE_SECS };
|
||||
let k = ctx.animate_bool_with_time_and_easing(Id::new("details-slide"), open, slide, easing::cubic_out);
|
||||
let k = ctx.animate_bool_with_time_and_easing(Id::unique("details-slide"), open, slide, easing::cubic_out);
|
||||
let Some(id) = self.detail_shown.clone() else { return };
|
||||
let Some(details) = AppDetails::by_id(&id) else {
|
||||
self.detail = None;
|
||||
@@ -140,29 +140,35 @@ impl LauncherUi {
|
||||
let Some(entry) = entry else { return };
|
||||
let wide = panel.width() >= theme::PAGE_COLUMNS_FROM;
|
||||
let (previous, next) = self.neighbour_apps(id);
|
||||
egui::Area::new(Id::new("details-drawer")).order(Order::Foreground).fixed_pos(panel.min).constrain(false).show(ctx, |ui| {
|
||||
ui.set_opacity(k.clamp(0.0, 1.0));
|
||||
let painter = ui.painter();
|
||||
painter.add(theme::shadow(t).as_shape(panel, CornerRadius::ZERO));
|
||||
painter.rect_filled(panel, CornerRadius::ZERO, t.bg);
|
||||
painter.rect_stroke(panel, CornerRadius::ZERO, Stroke::new(1.0, t.line_strong), egui::StrokeKind::Inside);
|
||||
egui::Area::new(Id::unique("details-drawer"))
|
||||
.order(Order::Foreground)
|
||||
.fixed_pos(panel.min)
|
||||
.constrain(false)
|
||||
.role(egui::Role::Dialog)
|
||||
.accessible_name(format!("{}: {}", tl!("App details"), entry.app.name))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_opacity(k.clamp(0.0, 1.0));
|
||||
let painter = ui.painter();
|
||||
painter.add(theme::shadow(t).as_shape(panel, CornerRadius::ZERO));
|
||||
painter.rect_filled(panel, CornerRadius::ZERO, t.bg);
|
||||
painter.rect_stroke(panel, CornerRadius::ZERO, Stroke::new(1.0, t.line_strong), egui::StrokeKind::Inside);
|
||||
|
||||
ui.set_min_size(panel.size());
|
||||
ui.set_max_size(panel.size());
|
||||
// A bar that stays on screen says there is more below the fold; in the app's colour.
|
||||
let page_visuals = crate::sheet::accent_scroll(ui, t, t.app_ink(entry.app));
|
||||
// One scroll position per app, so the next app opens at its top.
|
||||
egui::ScrollArea::vertical().id_salt(("details-scroll", id.to_string())).auto_shrink([false, false]).show(ui, |ui| {
|
||||
// The bar's colours are for the bar alone.
|
||||
*ui.visuals_mut() = page_visuals;
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
self.details_hero(ui, &entry, details, t, (previous.as_deref(), next.as_deref()), wide);
|
||||
egui::Frame::new().inner_margin(Margin::same(PAD as i8)).show(ui, |ui| {
|
||||
self.details_body(ui, &entry, details, wide, t);
|
||||
ui.set_min_size(panel.size());
|
||||
ui.set_max_size(panel.size());
|
||||
// A bar that stays on screen says there is more below the fold; in the app's colour.
|
||||
let page_visuals = crate::sheet::accent_scroll(ui, t, t.app_ink(entry.app));
|
||||
// One scroll position per app, so the next app opens at its top.
|
||||
egui::ScrollArea::vertical().id_salt(("details-scroll", id.to_string())).auto_shrink([false, false]).show(ui, |ui| {
|
||||
// The bar's colours are for the bar alone.
|
||||
*ui.visuals_mut() = page_visuals;
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
self.details_hero(ui, &entry, details, t, (previous.as_deref(), next.as_deref()), wide);
|
||||
egui::Frame::new().inner_margin(Margin::same(PAD as i8)).show(ui, |ui| {
|
||||
self.details_body(ui, &entry, details, wide, t);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The page below the hero: the story, pictures, features and release facts, in two columns
|
||||
@@ -286,7 +292,7 @@ impl LauncherUi {
|
||||
|
||||
/// The page behind the drawer, dimmed (egui can't blur what is under a layer); a click on it closes the drawer.
|
||||
fn details_scrim(&mut self, ctx: &egui::Context, area: Rect, k: f32, t: &Tokens) {
|
||||
egui::Area::new(Id::new("details-scrim")).order(Order::Middle).fixed_pos(area.min).show(ctx, |ui| {
|
||||
egui::Area::new(Id::unique("details-scrim")).order(Order::Middle).fixed_pos(area.min).show(ctx, |ui| {
|
||||
let (rect, resp) = ui.allocate_exact_size(area.size(), Sense::click());
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, theme::scrim(t, k));
|
||||
if resp.on_hover_cursor(egui::CursorIcon::Default).clicked() {
|
||||
@@ -348,6 +354,7 @@ impl LauncherUi {
|
||||
let hero_w = ui.available_width();
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(icon) = crate::icons::app(app.id) {
|
||||
// Decorative: the wordmark beside it carries the app name.
|
||||
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(ICON, ICON)));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
@@ -423,46 +430,51 @@ impl LauncherUi {
|
||||
let src = self.picture_source(details.id, shot.key);
|
||||
let failed = self.failure(details.id, shot.key);
|
||||
let full = ctx.content_rect();
|
||||
egui::Area::new(Id::new("details-lightbox")).order(Order::Tooltip).fixed_pos(full.min).show(ctx, |ui| {
|
||||
let (rect, backdrop) = ui.allocate_exact_size(full.size(), Sense::click());
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, theme::backdrop());
|
||||
// As large as the window allows, but no larger than 1400 points: the files are 1200 px wide.
|
||||
let room = rect.shrink2(vec2(112.0, 96.0));
|
||||
let w = room.width().min(room.height() / SHOT_ASPECT).clamp(1.0, 1400.0);
|
||||
let pic = Rect::from_center_size(rect.center(), vec2(w, w * SHOT_ASPECT));
|
||||
match src {
|
||||
Some(src) => egui::Image::new(src).paint_at(ui, pic),
|
||||
// Flipped to a picture that hasn't arrived: the same frame, saying so.
|
||||
None => placeholder(ui, pic, Id::new("lightbox-placeholder"), failed.as_deref(), t, true),
|
||||
}
|
||||
ui.painter().rect_stroke(pic, CornerRadius::ZERO, Stroke::new(1.0, theme::backdrop_ink(40)), egui::StrokeKind::Outside);
|
||||
let caption = if count > 1 { format!("{} · {}/{count}", shot.caption, at + 1) } else { shot.caption.to_string() };
|
||||
let ink = theme::backdrop_ink(190);
|
||||
let galley = widgets::hud_galley(ui, &caption, ink);
|
||||
let caption_at = pos2(rect.center().x - galley.size().x / 2.0, pic.bottom() + 14.0);
|
||||
ui.painter().galley(caption_at, galley, ink);
|
||||
egui::Area::new(Id::unique("details-lightbox"))
|
||||
.order(Order::Tooltip)
|
||||
.fixed_pos(full.min)
|
||||
.role(egui::Role::Dialog)
|
||||
.accessible_name(format!("{}: {}", tl!("Enlarge picture"), CraftApp::by_id(details.id).map_or("", |app| app.name)))
|
||||
.show(ctx, |ui| {
|
||||
let (rect, backdrop) = ui.allocate_exact_size(full.size(), Sense::click());
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, theme::backdrop());
|
||||
// As large as the window allows, but no larger than 1400 points: the files are 1200 px wide.
|
||||
let room = rect.shrink2(vec2(112.0, 96.0));
|
||||
let w = room.width().min(room.height() / SHOT_ASPECT).clamp(1.0, 1400.0);
|
||||
let pic = Rect::from_center_size(rect.center(), vec2(w, w * SHOT_ASPECT));
|
||||
match src {
|
||||
Some(src) => egui::Image::new(src).paint_at(ui, pic),
|
||||
// Flipped to a picture that hasn't arrived: the same frame, saying so.
|
||||
None => placeholder(ui, pic, Id::unique("lightbox-placeholder"), failed.as_deref(), t, true),
|
||||
}
|
||||
ui.painter().rect_stroke(pic, CornerRadius::ZERO, Stroke::new(1.0, theme::backdrop_ink(40)), egui::StrokeKind::Outside);
|
||||
let caption = if count > 1 { format!("{} · {}/{count}", shot.caption, at + 1) } else { shot.caption.to_string() };
|
||||
let ink = theme::backdrop_ink(190);
|
||||
let galley = widgets::hud_galley(ui, &caption, ink);
|
||||
let caption_at = pos2(rect.center().x - galley.size().x / 2.0, pic.bottom() + 14.0);
|
||||
ui.painter().galley(caption_at, galley, ink);
|
||||
|
||||
if count > 1 {
|
||||
let y = pic.center().y - ARROW / 2.0;
|
||||
let prev = Rect::from_min_size(pos2(pic.left() - 16.0 - ARROW, y), vec2(ARROW, ARROW));
|
||||
let next = Rect::from_min_size(pos2(pic.right() + 16.0, y), vec2(ARROW, ARROW));
|
||||
if arrow_button(ui, prev, Id::new("zoom-prev"), true, tl!("Previous picture (←)"), t) {
|
||||
self.flip_shot(count, false);
|
||||
if count > 1 {
|
||||
let y = pic.center().y - ARROW / 2.0;
|
||||
let prev = Rect::from_min_size(pos2(pic.left() - 16.0 - ARROW, y), vec2(ARROW, ARROW));
|
||||
let next = Rect::from_min_size(pos2(pic.right() + 16.0, y), vec2(ARROW, ARROW));
|
||||
if arrow_button(ui, prev, Id::unique("zoom-prev"), true, tl!("Previous picture (←)"), t) {
|
||||
self.flip_shot(count, false);
|
||||
}
|
||||
if arrow_button(ui, next, Id::unique("zoom-next"), false, tl!("Next picture (→)"), t) {
|
||||
self.flip_shot(count, true);
|
||||
}
|
||||
}
|
||||
if arrow_button(ui, next, Id::new("zoom-next"), false, tl!("Next picture (→)"), t) {
|
||||
self.flip_shot(count, true);
|
||||
let close =
|
||||
Rect::from_min_size(pos2(rect.right() - 16.0 - theme::CONTROL_H, rect.top() + 16.0), vec2(theme::CONTROL_H, theme::CONTROL_H));
|
||||
let mut done = backdrop.clicked();
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(close), |ui| {
|
||||
done |= widgets::glyph_button(ui, Glyph::Close, tl!("Close (Esc)")).clicked();
|
||||
});
|
||||
if done {
|
||||
self.detail_zoom = false;
|
||||
}
|
||||
}
|
||||
let close =
|
||||
Rect::from_min_size(pos2(rect.right() - 16.0 - theme::CONTROL_H, rect.top() + 16.0), vec2(theme::CONTROL_H, theme::CONTROL_H));
|
||||
let mut done = backdrop.clicked();
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(close), |ui| {
|
||||
done |= widgets::glyph_button(ui, Glyph::Close, tl!("Close (Esc)")).clicked();
|
||||
});
|
||||
if done {
|
||||
self.detail_zoom = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The lead screenshot, its caption, and a row of thumbnails to switch between them. A picture
|
||||
@@ -473,7 +485,7 @@ impl LauncherUi {
|
||||
let pic = match self.picture_source(details.id, current.key) {
|
||||
Some(lead) => {
|
||||
let image = ui.add(egui::Image::new(lead).fit_to_exact_size(vec2(w, w * SHOT_ASPECT)).sense(Sense::click()));
|
||||
image.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Enlarge picture")));
|
||||
image.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Enlarge picture")));
|
||||
let rect = image.rect;
|
||||
// The arrows below are added after the picture, so they keep their own clicks.
|
||||
if image.on_hover_cursor(egui::CursorIcon::ZoomIn).on_hover_text(tl!("Click to enlarge")).clicked() {
|
||||
@@ -484,7 +496,7 @@ impl LauncherUi {
|
||||
None => {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(w, w * SHOT_ASPECT), Sense::hover());
|
||||
let failed = self.failure(details.id, current.key);
|
||||
placeholder(ui, rect, Id::new("lead-placeholder"), failed.as_deref(), t, false);
|
||||
placeholder(ui, rect, Id::unique("lead-placeholder"), failed.as_deref(), t, false);
|
||||
rect
|
||||
}
|
||||
};
|
||||
@@ -498,10 +510,10 @@ impl LauncherUi {
|
||||
let y = pic.center().y - ARROW / 2.0;
|
||||
let prev = Rect::from_min_size(pos2(pic.left() + 10.0, y), vec2(ARROW, ARROW));
|
||||
let next = Rect::from_min_size(pos2(pic.right() - 10.0 - ARROW, y), vec2(ARROW, ARROW));
|
||||
if arrow_button(ui, prev, Id::new("shot-prev"), true, tl!("Previous picture (←)"), t) {
|
||||
if arrow_button(ui, prev, Id::unique("shot-prev"), true, tl!("Previous picture (←)"), t) {
|
||||
self.flip_shot(count, false);
|
||||
}
|
||||
if arrow_button(ui, next, Id::new("shot-next"), false, tl!("Next picture (→)"), t) {
|
||||
if arrow_button(ui, next, Id::unique("shot-next"), false, tl!("Next picture (→)"), t) {
|
||||
self.flip_shot(count, true);
|
||||
}
|
||||
widgets::hud(ui, &format!("{} · {}/{count}", current.caption, self.detail_shot.min(count - 1) + 1), t.faint);
|
||||
@@ -529,6 +541,7 @@ impl LauncherUi {
|
||||
}
|
||||
let stroke = if selected { Stroke::new(2.0, app_color) } else { t.hairline() };
|
||||
ui.painter().rect_stroke(resp.rect, CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, shot.caption));
|
||||
if resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(shot.caption).clicked() {
|
||||
self.detail_shot = i;
|
||||
}
|
||||
@@ -546,7 +559,7 @@ fn placeholder(ui: &mut Ui, rect: Rect, id: Id, failed: Option<&str>, t: &Tokens
|
||||
let galley = ui.painter().layout(text.to_string(), egui::FontId::proportional(13.5), ink, (rect.width() - 32.0).max(1.0));
|
||||
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, ink);
|
||||
let resp = ui.interact(rect, id, Sense::hover());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text));
|
||||
if let Some(why) = failed {
|
||||
resp.on_hover_text(why);
|
||||
}
|
||||
@@ -563,7 +576,7 @@ fn step_button(ui: &mut Ui, left: bool, tip: &str, t: &Tokens) -> bool {
|
||||
vec![pos2(c.x - s * 3.0, c.y - 5.5), pos2(c.x + s * 3.0, c.y), pos2(c.x - s * 3.0, c.y + 5.5)],
|
||||
Stroke::new(1.75, ink),
|
||||
));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip));
|
||||
resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked()
|
||||
}
|
||||
|
||||
@@ -575,7 +588,7 @@ fn link_buttons(ui: &mut Ui, app: &CraftApp) {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
for (label, url) in links {
|
||||
if widgets::button(ui, &format!("{label} ↗"), Kind::Secondary, 0.0).on_hover_text(url.as_str()).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
widgets::open_external_link(ui.ctx(), &url);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -608,6 +621,6 @@ fn arrow_button(ui: &mut Ui, rect: Rect, id: Id, left: bool, tip: &str, t: &Toke
|
||||
let (c, s) = (rect.center(), if left { -1.0 } else { 1.0 });
|
||||
let points = vec![pos2(c.x - s * 3.5, c.y - 6.0), pos2(c.x + s * 3.5, c.y), pos2(c.x - s * 3.5, c.y + 6.0)];
|
||||
painter.add(egui::Shape::line(points, Stroke::new(1.75, ink)));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip));
|
||||
resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ use crate::theme::{self, Tokens};
|
||||
use crate::widgets::{self, Kind};
|
||||
|
||||
impl LauncherUi {
|
||||
/// Draws the current modal and writes any edits made in it back to `self.dialog`. With no
|
||||
/// dialog it shows the next unacknowledged feed alert. Each dialog kind gets its own modal id
|
||||
/// so a smaller prompt cannot inherit a taller one's last size, and closing (button, Escape or
|
||||
/// a click outside) discards the dialog.
|
||||
pub(crate) fn dialogs(&mut self, ctx: &egui::Context, host: &mut dyn Host) {
|
||||
let Some(mut dialog) = self.dialog.clone() else {
|
||||
self.alert_popup(ctx);
|
||||
@@ -20,7 +24,7 @@ impl LauncherUi {
|
||||
// uninstall prompt would open as tall as Settings and never shrink (#88).
|
||||
// The large dialogs run their header to the card's edges; the small prompts keep a margin.
|
||||
let large = matches!(dialog, Dialog::Settings | Dialog::Notes(_));
|
||||
let modal = egui::Modal::new(egui::Id::new("dialog").with(dialog.key())).frame(if large {
|
||||
let modal = egui::Modal::new(egui::Id::unique("dialog").with(dialog.key())).frame(if large {
|
||||
crate::sheet::frame(&t)
|
||||
} else {
|
||||
egui::Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(theme::PAD as i8)
|
||||
@@ -45,7 +49,7 @@ impl LauncherUi {
|
||||
fn alert_popup(&mut self, ctx: &egui::Context) {
|
||||
let Some(alert) = self.alerts.iter().find(|a| !self.settings.dismissed_notices.contains(&a.id)).cloned() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let modal = egui::Modal::new(egui::Id::new("feed_alert").with(&alert.id)).frame(
|
||||
let modal = egui::Modal::new(egui::Id::unique("feed_alert").with(&alert.id)).frame(
|
||||
egui::Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(theme::PAD as i8),
|
||||
);
|
||||
let mut ok = false;
|
||||
@@ -64,7 +68,7 @@ impl LauncherUi {
|
||||
if let Some(link) = &alert.link
|
||||
&& widgets::button(ui, tl!("Open link"), Kind::Secondary, 110.0).on_hover_text(link.as_str()).clicked()
|
||||
{
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(link.clone()));
|
||||
widgets::open_external_link(ui.ctx(), link);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -75,10 +79,14 @@ impl LauncherUi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the optional icon beside a heading and an optional subtitle. The icon is decorative so
|
||||
/// assistive technology hears the title once, and an empty subtitle is omitted.
|
||||
fn title(ui: &mut egui::Ui, icon: Option<egui::ImageSource<'static>>, title: &str, subtitle: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(icon) = icon {
|
||||
// The heading beside it carries the title; an icon with the same accessible
|
||||
// name would announce the dialog title twice.
|
||||
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(40.0, 40.0)));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
@@ -131,6 +139,9 @@ impl LauncherUi {
|
||||
close
|
||||
}
|
||||
|
||||
/// Offers a new launcher version and returns whether the dialog should close. With self-update
|
||||
/// it queues `UpdateLauncher` and closes; otherwise it opens the platform download (or the
|
||||
/// release page) and closes. "Later" closes without doing either.
|
||||
fn launcher_update_dialog(&mut self, ui: &mut egui::Ui, r: &artcraft_launcher_core::Release) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.set_width(480.0f32.min(ui.ctx().content_rect().width() - 80.0));
|
||||
@@ -152,7 +163,7 @@ impl LauncherUi {
|
||||
.on_hover_text(tl!("Downloads the new version, replaces this one and opens it again"))
|
||||
.clicked()
|
||||
{
|
||||
self.send(Command::UpdateLauncher);
|
||||
self.act_command(Command::UpdateLauncher);
|
||||
close = true;
|
||||
}
|
||||
} else {
|
||||
@@ -161,7 +172,7 @@ impl LauncherUi {
|
||||
.and_then(|p| crate::views::launcher_download(r, p, crate::settings::is_flatpak_sandbox()))
|
||||
.unwrap_or_else(|| r.html_url.clone());
|
||||
if widgets::button(ui, tl!("Download"), Kind::Primary, 0.0).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
widgets::open_external_link(ui.ctx(), &url);
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
@@ -173,6 +184,9 @@ impl LauncherUi {
|
||||
close
|
||||
}
|
||||
|
||||
/// Confirms removing an installed app and returns `true` when the dialog should close, including
|
||||
/// when the app entry has disappeared. It queues `Uninstall` only on confirmation and explains
|
||||
/// the removal differently for each install source.
|
||||
fn uninstall_dialog(&mut self, ui: &mut egui::Ui, id: &str) -> bool {
|
||||
let Some(entry) = self.entry(id).cloned() else { return true };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -197,7 +211,7 @@ impl LauncherUi {
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::button(ui, tl!("Uninstall"), Kind::Danger, 110.0).clicked() {
|
||||
self.send(Command::Uninstall { app: id.to_string() });
|
||||
self.act_command(Command::Uninstall { app: id.to_string() });
|
||||
close = true;
|
||||
}
|
||||
if widgets::button(ui, tl!("Cancel"), Kind::Secondary, 90.0).clicked() {
|
||||
@@ -208,6 +222,9 @@ impl LauncherUi {
|
||||
close
|
||||
}
|
||||
|
||||
/// Warns that `path` already exists and asks whether to replace it, returning `true` when the
|
||||
/// dialog should close, including when the app entry is gone. `Replace` is queued at the action
|
||||
/// boundary, not sent inline.
|
||||
fn replace_dialog(&mut self, ui: &mut egui::Ui, id: &str, path: &str) -> bool {
|
||||
let Some(app) = self.entry(id).map(|e| e.app) else { return true };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -222,7 +239,7 @@ impl LauncherUi {
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::button(ui, tl!("Replace"), Kind::Danger, 110.0).clicked() {
|
||||
self.send(Command::Replace { app: id.to_string() });
|
||||
self.act_command(Command::Replace { app: id.to_string() });
|
||||
close = true;
|
||||
}
|
||||
if widgets::button(ui, tl!("Cancel"), Kind::Secondary, 90.0).clicked() {
|
||||
@@ -233,6 +250,10 @@ impl LauncherUi {
|
||||
close
|
||||
}
|
||||
|
||||
/// Lets the user choose this app's install folder and returns `true` when the dialog should
|
||||
/// close, including when the app entry is gone. An empty field removes the per-app override, a
|
||||
/// non-empty one persists it, and any changed configuration is sent before the install is queued. The path
|
||||
/// itself is not validated here.
|
||||
fn install_dialog(&mut self, ui: &mut egui::Ui, id: &str, folder: &mut String, host: &mut dyn Host) -> bool {
|
||||
let Some(app) = self.entry(id).map(|e| e.app) else { return true };
|
||||
ui.set_width(440.0f32.min(ui.ctx().content_rect().width() - 80.0));
|
||||
@@ -252,7 +273,7 @@ impl LauncherUi {
|
||||
self.settings.install_locations.per_app.insert(id.to_string(), folder.clone());
|
||||
}
|
||||
self.configure();
|
||||
self.send(Command::Install { app: id.to_string() });
|
||||
self.act_command(Command::Install { app: id.to_string() });
|
||||
close = true;
|
||||
}
|
||||
close |= widgets::button(ui, tl!("Cancel"), Kind::Secondary, 90.0).clicked();
|
||||
|
||||
@@ -244,6 +244,7 @@ column Latest Nejnovější
|
||||
column Released Vydáno
|
||||
filter Installed Nainstalováno
|
||||
App details Podrobnosti o aplikaci
|
||||
More actions for {name} Další akce pro {name}
|
||||
Close (Esc) Zavřít (Esc)
|
||||
Features Funkce
|
||||
Latest release Nejnovější verze
|
||||
|
||||
|
@@ -340,6 +340,7 @@ filter Installed Installiert
|
||||
ArtCraft on GitHub ArtCraft auf GitHub
|
||||
Join the ArtCraft Discord Dem ArtCraft-Discord beitreten
|
||||
App details App-Details
|
||||
More actions for {name} Weitere Aktionen für {name}
|
||||
Close (Esc) Schließen (Esc)
|
||||
Features Funktionen
|
||||
Latest release Neueste Version
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 16.
|
@@ -257,6 +257,7 @@ column Latest Última
|
||||
column Released Publicada
|
||||
filter Installed Instaladas
|
||||
App details Detalles de la app
|
||||
More actions for {name} Más acciones para {name}
|
||||
Close (Esc) Cerrar (Esc)
|
||||
Features Funciones
|
||||
Latest release Última versión
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 48.
|
@@ -338,6 +338,7 @@ filter Installed Installées
|
||||
ArtCraft on GitHub ArtCraft sur GitHub
|
||||
Join the ArtCraft Discord Rejoindre le Discord d'ArtCraft
|
||||
App details Détails de l'application
|
||||
More actions for {name} Autres actions pour {name}
|
||||
Close (Esc) Fermer (Esc)
|
||||
Features Fonctionnalités
|
||||
Latest release Dernière version
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 50.
|
@@ -268,6 +268,7 @@ column Released Dátum
|
||||
# Contextual: the filter tab names a group (the status pill says "Installed")
|
||||
filter Installed Telepítve
|
||||
App details Alkalmazás részletei
|
||||
More actions for {name} További műveletek: {name}
|
||||
Close (Esc) Bezárás (Esc)
|
||||
Features Funkciók
|
||||
Latest release Legújabb kiadás
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 16.
|
@@ -270,6 +270,7 @@ column Released 公開日
|
||||
# Contextual: the filter tab names a group (the status pill says "Installed")
|
||||
filter Installed インストール済み
|
||||
App details アプリの詳細
|
||||
More actions for {name} {name} のその他の操作
|
||||
Close (Esc) 閉じる (Esc)
|
||||
Features 機能
|
||||
Latest release 最新リリース
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 14 and column 44.
|
@@ -36,4 +36,6 @@ mod tests;
|
||||
|
||||
pub use detect::{detect_system_lang_in_background, set_system_locales, set_test_lang, system_lang};
|
||||
pub use lang::{LANGUAGES, Lang, LangInfo, Script, lang_from_tag};
|
||||
#[cfg(test)]
|
||||
pub(crate) use translate::{StrictCjkDrawing, drew_cjk};
|
||||
pub use translate::{current, fmt, has, message, msg, set_current, t, t_id, tn, tr, tr_ctx, tr_id, trn};
|
||||
|
||||
@@ -269,6 +269,7 @@ column Released Data wydania
|
||||
# Contextual: the filter tab names a group (the status pill says "Installed")
|
||||
filter Installed Zainstalowane
|
||||
App details Szczegóły aplikacji
|
||||
More actions for {name} Więcej działań: {name}
|
||||
Close (Esc) Zamknij (Esc)
|
||||
Features Funkcje
|
||||
Latest release Najnowsze wydanie
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 15.
|
@@ -328,6 +328,7 @@ filter Installed Instalados
|
||||
ArtCraft on GitHub ArtCraft no GitHub
|
||||
Join the ArtCraft Discord Entrar no Discord da ArtCraft
|
||||
App details Detalhes do aplicativo
|
||||
More actions for {name} Mais ações para {name}
|
||||
Close (Esc) Fechar (Esc)
|
||||
Features Recursos
|
||||
Latest release Último lançamento
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 255 and column 66.
|
@@ -336,6 +336,7 @@ filter Installed Установлены
|
||||
ArtCraft on GitHub ArtCraft на GitHub
|
||||
Join the ArtCraft Discord Присоединиться к Discord ArtCraft
|
||||
App details О приложении
|
||||
More actions for {name} Другие действия для {name}
|
||||
Close (Esc) Закрыть (Esc)
|
||||
Features Возможности
|
||||
Latest release Последний релиз
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 44.
|
@@ -30,6 +30,8 @@ pub fn t(s: &str) -> &str {
|
||||
|
||||
/// Translate an English UI string; unknown strings come back unchanged.
|
||||
pub fn tr(lang: Lang, s: &str) -> &str {
|
||||
#[cfg(test)]
|
||||
strict::check(lang);
|
||||
lang.catalog().plain(s).unwrap_or(s)
|
||||
}
|
||||
|
||||
@@ -48,6 +50,9 @@ pub fn t_id<'a>(id: &str, label: &'a str) -> &'a str {
|
||||
tr_id(current(), id, label)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use strict::{StrictCjkDrawing, drew_cjk};
|
||||
|
||||
/// Fill `{name}` placeholders. Unknown placeholders are left as written.
|
||||
pub fn fmt(template: &str, args: &[(&str, &str)]) -> String {
|
||||
let mut out = template.to_string();
|
||||
@@ -95,3 +100,65 @@ pub fn msg(s: &str) -> String {
|
||||
*last = Some((lang.code(), s.to_string(), out.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
/// A test-only strict drawing policy: while a [`StrictCjkDrawing`] guard is in scope, translating
|
||||
/// text into a CJK language whose own face is not the one installed in the drawing context panics,
|
||||
/// instead of quietly drawing tofu. It is how a unit test proves a frame cannot select Chinese
|
||||
/// before the next pass's font gate has installed the face.
|
||||
#[cfg(test)]
|
||||
mod strict {
|
||||
use std::cell::Cell;
|
||||
|
||||
use super::Lang;
|
||||
|
||||
thread_local! {
|
||||
/// `Some(installed)` while the policy is on, where `installed` is the CJK language whose
|
||||
/// face the context has (`None` when it has no CJK face).
|
||||
static POLICY: Cell<Option<Option<Lang>>> = const { Cell::new(None) };
|
||||
/// Whether any CJK text was translated while the policy was on. Cleared by each guard.
|
||||
static DREW_CJK: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Turns the strict policy on for this thread and restores the previous policy on drop.
|
||||
pub(crate) struct StrictCjkDrawing {
|
||||
previous: Option<Option<Lang>>,
|
||||
}
|
||||
|
||||
impl StrictCjkDrawing {
|
||||
/// `installed` is the CJK language whose face the drawing context has, if any.
|
||||
pub(crate) fn on(installed: Option<Lang>) -> Self {
|
||||
DREW_CJK.with(|drawn| drawn.set(false));
|
||||
let previous = POLICY.with(|policy| policy.replace(Some(installed)));
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StrictCjkDrawing {
|
||||
fn drop(&mut self) {
|
||||
POLICY.with(|policy| policy.set(self.previous.take()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Called from [`super::tr`] before a string is drawn.
|
||||
pub(crate) fn check(lang: Lang) {
|
||||
let _ = POLICY.try_with(|policy| {
|
||||
let Some(installed) = policy.get() else { return };
|
||||
if !lang.needs_cjk_font() {
|
||||
return;
|
||||
}
|
||||
DREW_CJK.with(|drawn| drawn.set(true));
|
||||
assert_eq!(
|
||||
installed,
|
||||
Some(lang),
|
||||
"strict CJK drawing policy: {} text was drawn while the context had {:?}; a CJK face is drawable only after the font gate's next pass",
|
||||
lang.code(),
|
||||
installed.map(Lang::code),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether CJK text was translated while the most recent guard was on.
|
||||
pub(crate) fn drew_cjk() -> bool {
|
||||
DREW_CJK.with(|drawn| drawn.get())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,7 @@ figcraft Interface design and prototyping 界面设计与原型制作
|
||||
Flatpak bundle Flatpak 包
|
||||
Tarball (.tar.gz) 压缩包 (.tar.gz)
|
||||
App details 应用详情
|
||||
More actions for {name} {name} 的更多操作
|
||||
Close (Esc) 关闭(Esc)
|
||||
Features 功能特性
|
||||
Latest release 最新发布
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 3.
|
@@ -20,7 +20,7 @@ pub fn app_image(ctx: &egui::Context, id: &str, in_colour: bool) -> Option<egui:
|
||||
if let Some(colour) = &colour {
|
||||
let _ = colour.load_for_size(ctx, egui::Vec2::splat(64.0));
|
||||
}
|
||||
let key = egui::Id::new(("grey-app-icon", id));
|
||||
let key = egui::Id::unique(("grey-app-icon", id));
|
||||
if let Some(texture) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
|
||||
return Some(egui::Image::new(&texture));
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(crate) fn unsmooth_wheel(ctx: &egui::Context, raw: &mut RawInput) {
|
||||
let page = raw.screen_rect.unwrap_or_else(|| ctx.viewport_rect()).height();
|
||||
let mut events = Vec::with_capacity(raw.events.len());
|
||||
for event in std::mem::take(&mut raw.events) {
|
||||
let Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } = &event else {
|
||||
let Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers, source } = &event else {
|
||||
events.push(event);
|
||||
continue;
|
||||
};
|
||||
@@ -51,7 +51,8 @@ pub(crate) fn unsmooth_wheel(ctx: &egui::Context, raw: &mut RawInput) {
|
||||
events.push(event);
|
||||
continue;
|
||||
}
|
||||
let step = Event::MouseWheel { unit: MouseWheelUnit::Point, delta: points / steps, phase: TouchPhase::Move, modifiers: *modifiers };
|
||||
let step =
|
||||
Event::MouseWheel { unit: MouseWheelUnit::Point, delta: points / steps, phase: TouchPhase::Move, modifiers: *modifiers, source: *source };
|
||||
events.extend(std::iter::repeat_n(step, steps as usize));
|
||||
}
|
||||
raw.events = events;
|
||||
@@ -59,6 +60,7 @@ pub(crate) fn unsmooth_wheel(ctx: &egui::Context, raw: &mut RawInput) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::MouseWheelSource;
|
||||
use egui::{Modifiers, Vec2, vec2};
|
||||
|
||||
use super::*;
|
||||
@@ -66,7 +68,7 @@ mod tests {
|
||||
const FRAME: f64 = 1.0 / 60.0;
|
||||
|
||||
fn wheel(unit: MouseWheelUnit, delta: Vec2, modifiers: Modifiers) -> Event {
|
||||
Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers }
|
||||
Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers, source: MouseWheelSource::Wheel }
|
||||
}
|
||||
|
||||
/// One frame: how far egui scrolls in it.
|
||||
@@ -129,7 +131,13 @@ mod tests {
|
||||
wheel(MouseWheelUnit::Line, vec2(0.0, f32::NAN), Modifiers::NONE),
|
||||
wheel(MouseWheelUnit::Page, vec2(f32::INFINITY, 0.0), Modifiers::NONE),
|
||||
wheel(MouseWheelUnit::Point, vec2(0.0, 1.0e9), Modifiers::NONE),
|
||||
Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 40.0), phase: TouchPhase::Start, modifiers: Modifiers::NONE },
|
||||
Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, 40.0),
|
||||
phase: TouchPhase::Start,
|
||||
modifiers: Modifiers::NONE,
|
||||
source: MouseWheelSource::Unknown,
|
||||
},
|
||||
];
|
||||
let mut raw = RawInput { events: events.clone(), ..Default::default() };
|
||||
unsmooth_wheel(&ctx, &mut raw);
|
||||
@@ -137,6 +145,23 @@ mod tests {
|
||||
assert_eq!(format!("{:?}", raw.events), format!("{events:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_step_keeps_the_wheel_source() {
|
||||
let ctx = egui::Context::default();
|
||||
for source in [MouseWheelSource::Wheel, MouseWheelSource::Trackpad, MouseWheelSource::Momentum, MouseWheelSource::Unknown] {
|
||||
let event =
|
||||
Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, -3.0), phase: TouchPhase::Move, modifiers: Modifiers::NONE, source };
|
||||
let mut raw = RawInput { events: vec![event], ..Default::default() };
|
||||
unsmooth_wheel(&ctx, &mut raw);
|
||||
assert!(raw.events.len() > 1, "{source:?} was not split");
|
||||
assert!(
|
||||
raw.events.iter().all(|event| matches!(event, Event::MouseWheel { source: kept, .. } if *kept == source)),
|
||||
"{source:?} was not preserved: {:?}",
|
||||
raw.events
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_styles_follow_the_setting() {
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
@@ -67,7 +67,7 @@ fn show_lines(ui: &mut Ui, notes: &str, scale: f32) {
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
let label = crate::i18n::fmt(tl!("Show {n} more changes"), &[("n", &bullets.saturating_sub(SHOWN).to_string())]);
|
||||
egui::CollapsingHeader::new(label).id_salt(ui.id().with(("more-notes", notes.len()))).show(ui, |ui| {
|
||||
egui::CollapsingHeader::new(label).id_salt(ui.scope_id().with(("more-notes", notes.len()))).show(ui, |ui| {
|
||||
for line in rest {
|
||||
draw(ui, line, &t, scale);
|
||||
}
|
||||
|
||||
@@ -12,17 +12,29 @@ use crate::theme::{self, ThemeChoice, Tokens};
|
||||
use crate::widgets::{self, Cell, Kind};
|
||||
|
||||
/// A label with its control at the right; the control drops under the label when they don't fit.
|
||||
fn row(ui: &mut Ui, label: &str, control_width: f32, add: &mut dyn FnMut(&mut Ui)) {
|
||||
///
|
||||
/// The control receives the label's node id so it can be associated with the visible text for
|
||||
/// accessibility (0.37's kittest fails unnamed input widgets).
|
||||
fn row(ui: &mut Ui, label: &str, control_width: f32, add: &mut dyn FnMut(&mut Ui, egui::Id)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = egui::TextStyle::Body.resolve(ui.style());
|
||||
let label_width = ui.painter().layout_no_wrap(label.to_string(), font, t.ink).size().x;
|
||||
if label_width + control_width + ui.spacing().item_spacing.x > ui.available_width() {
|
||||
ui.label(label);
|
||||
add(ui);
|
||||
let label = ui.label(label);
|
||||
add(ui, label.id);
|
||||
} else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| add(ui));
|
||||
let label = ui.label(label);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// A ComboBox grows to the width its layout offers, so leaving it loose made it
|
||||
// start at the label's last glyph and paint over the words at a narrow dialog
|
||||
// width. A fixed-width allocation at the right keeps it clear of the label and
|
||||
// still leaves the dialog itself as narrow as before.
|
||||
let width = control_width.min(ui.available_width());
|
||||
ui.allocate_ui_with_layout(vec2(width, theme::CONTROL_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
add(ui, label.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -115,22 +127,33 @@ impl LauncherUi {
|
||||
);
|
||||
}
|
||||
|
||||
/// The language picker. It changes the preference immediately but keeps this pass on a language
|
||||
/// that can be drawn now; a CJK face is installed by the next pass's font gate. When that face
|
||||
/// is unavailable, an English-only notice explains why the interface stays English.
|
||||
fn settings_language(&mut self, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut lang_code = self.settings.language.clone();
|
||||
let installed = self.cjk_font;
|
||||
sheet::panel(ui, tl!("Language"), |ui| {
|
||||
sheet::panel_with_heading(ui, tl!("Language"), |ui, heading| {
|
||||
let width = ui.available_width();
|
||||
egui::ComboBox::from_id_salt("language").selected_text(Settings::language_label(&lang_code, installed)).width(width).show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut lang_code, Settings::LANGUAGE_AUTO.to_string(), tl!("Auto"));
|
||||
for lang in crate::i18n::Lang::for_picker() {
|
||||
ui.selectable_value(&mut lang_code, lang.code().to_string(), Settings::language_label(lang.code(), installed));
|
||||
}
|
||||
});
|
||||
egui::ComboBox::from_id_salt("language")
|
||||
.selected_text(Settings::language_label(&lang_code, installed))
|
||||
.width(width)
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut lang_code, Settings::LANGUAGE_AUTO.to_string(), tl!("Auto"));
|
||||
for lang in crate::i18n::Lang::for_picker() {
|
||||
ui.selectable_value(&mut lang_code, lang.code().to_string(), Settings::language_label(lang.code(), installed));
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(heading);
|
||||
});
|
||||
if lang_code != self.settings.language {
|
||||
self.settings.language = lang_code;
|
||||
crate::i18n::set_current(crate::app::effective_lang(&self.settings.language));
|
||||
// A CJK face selected here is installed at the next pass's font gate; keep this pass on
|
||||
// a language that can be drawn now rather than asking for missing glyphs.
|
||||
crate::i18n::set_current(crate::app::drawable_lang(&self.settings.language, self.cjk_font));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let pref = crate::i18n::Lang::from_pref(&self.settings.language);
|
||||
if pref.needs_cjk_font() && !theme::cjk_font_available(pref) {
|
||||
@@ -142,15 +165,23 @@ impl LauncherUi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update scheduling and opt-ins. Values are written live; enabling automatic updates while the
|
||||
/// backend manages apps runs a pass over pending updates at once. The automatic-update checkbox
|
||||
/// is hidden on hosts that cannot manage apps.
|
||||
fn settings_updates(&mut self, ui: &mut Ui) {
|
||||
sheet::panel(ui, tl!("Updates"), |ui| {
|
||||
let mut every = self.settings.check_every;
|
||||
row(ui, tl!("Check for updates"), 150.0, &mut |ui| {
|
||||
egui::ComboBox::from_id_salt("every").selected_text(every.label()).width(150.0).show_ui(ui, |ui| {
|
||||
for e in CheckEvery::ALL {
|
||||
ui.selectable_value(&mut every, e, e.label());
|
||||
}
|
||||
});
|
||||
row(ui, tl!("Check for updates"), 150.0, &mut |ui, label| {
|
||||
egui::ComboBox::from_id_salt("every")
|
||||
.selected_text(every.label())
|
||||
.width(150.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for e in CheckEvery::ALL {
|
||||
ui.selectable_value(&mut every, e, e.label());
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(label);
|
||||
});
|
||||
self.settings.check_every = every;
|
||||
if self.caps.manage_apps
|
||||
@@ -202,13 +233,18 @@ impl LauncherUi {
|
||||
.copied()
|
||||
.filter(|kind| matches!(kind, PackageKind::Msi | PackageKind::PortableZip))
|
||||
.or(Some(PackageKind::Msi));
|
||||
row(ui, tl!("Install new apps as"), 210.0, &mut |ui| {
|
||||
let label = choice.map_or_else(|| crate::i18n::t(PackageKind::Msi.label()), |kind| crate::i18n::t(kind.label()));
|
||||
egui::ComboBox::from_id_salt("windows-kind").selected_text(label).width(210.0).show_ui(ui, |ui| {
|
||||
for kind in [PackageKind::Msi, PackageKind::PortableZip] {
|
||||
ui.selectable_value(&mut choice, Some(kind), crate::i18n::t(kind.label()));
|
||||
}
|
||||
});
|
||||
row(ui, tl!("Install new apps as"), 210.0, &mut |ui, label| {
|
||||
let shown = choice.map_or_else(|| crate::i18n::t(PackageKind::Msi.label()), |kind| crate::i18n::t(kind.label()));
|
||||
egui::ComboBox::from_id_salt("windows-kind")
|
||||
.selected_text(shown)
|
||||
.width(210.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for kind in [PackageKind::Msi, PackageKind::PortableZip] {
|
||||
ui.selectable_value(&mut choice, Some(kind), crate::i18n::t(kind.label()));
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(label);
|
||||
});
|
||||
self.settings.preferred_kinds = choice.into_iter().collect();
|
||||
note(ui, tl!("Updates always use the format an app was installed with."));
|
||||
@@ -229,31 +265,39 @@ impl LauncherUi {
|
||||
None
|
||||
};
|
||||
let mut choice = self.settings.preferred_kinds.first().copied().or(default_kind);
|
||||
row(ui, tl!("Install new apps as"), 210.0, &mut |ui| {
|
||||
row(ui, tl!("Install new apps as"), 210.0, &mut |ui, label| {
|
||||
let default_label = match default_kind {
|
||||
Some(k) => crate::i18n::t(k.label()),
|
||||
None => tl!("System package (recommended)"),
|
||||
};
|
||||
let label = choice.map_or(default_label, |k| crate::i18n::t(k.label()));
|
||||
egui::ComboBox::from_id_salt("kind").selected_text(label).width(210.0).show_ui(ui, |ui| {
|
||||
if !is_flatpak && !is_appimage {
|
||||
ui.selectable_value(&mut choice, None, tl!("System package (recommended)"));
|
||||
}
|
||||
for k in [PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::AppImage] {
|
||||
ui.selectable_value(&mut choice, Some(k), crate::i18n::t(k.label()));
|
||||
}
|
||||
// The tarball is unpacked and run inside the launcher's own sandbox, so it
|
||||
// is only ever on offer there (as the fallback when the host has no Flatpak).
|
||||
if is_flatpak {
|
||||
ui.selectable_value(&mut choice, Some(PackageKind::TarGz), crate::i18n::t(PackageKind::TarGz.label()));
|
||||
}
|
||||
});
|
||||
let shown = choice.map_or(default_label, |k| crate::i18n::t(k.label()));
|
||||
egui::ComboBox::from_id_salt("kind")
|
||||
.selected_text(shown)
|
||||
.width(210.0)
|
||||
.show_ui(ui, |ui| {
|
||||
if !is_flatpak && !is_appimage {
|
||||
ui.selectable_value(&mut choice, None, tl!("System package (recommended)"));
|
||||
}
|
||||
for k in [PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::AppImage] {
|
||||
ui.selectable_value(&mut choice, Some(k), crate::i18n::t(k.label()));
|
||||
}
|
||||
// The tarball is unpacked and run inside the launcher's own sandbox, so it
|
||||
// is only ever on offer there (as the fallback when the host has no Flatpak).
|
||||
if is_flatpak {
|
||||
ui.selectable_value(&mut choice, Some(PackageKind::TarGz), crate::i18n::t(PackageKind::TarGz.label()));
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(label);
|
||||
});
|
||||
self.settings.preferred_kinds = choice.into_iter().collect();
|
||||
note(ui, tl!("Updates always use the format an app was installed with."));
|
||||
});
|
||||
}
|
||||
|
||||
/// Tray, desktop and login-item choices. The whole panel is omitted when the host cannot manage
|
||||
/// apps or has no tray, desktops and login item; a failed login-item change becomes a UI toast
|
||||
/// instead of being ignored.
|
||||
fn settings_background(&mut self, ui: &mut Ui, host: &mut dyn Host) {
|
||||
// Nothing to show on a host with no tray, desktops or login item: no empty box.
|
||||
if !self.caps.manage_apps || !(host.has_tray() || host.has_desktops() || host.login_item().is_some()) {
|
||||
@@ -272,12 +316,13 @@ impl LauncherUi {
|
||||
&& ui.checkbox(&mut on, tl!("Start ArtCraft Launcher when I log in")).changed()
|
||||
&& let Err(e) = host.set_login_item(on)
|
||||
{
|
||||
let ctx = ui.ctx().clone();
|
||||
self.toast(&ctx, fmt(tl!("Couldn't change the login item: {e}"), &[("e", &e)]), true);
|
||||
self.toast_from_ui(fmt(tl!("Couldn't change the login item: {e}"), &[("e", &e)]), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Community links (Discord, website, GitHub and the social icons) opened through the safe
|
||||
/// external-link path; it queues no app actions.
|
||||
fn settings_community(&mut self, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
sheet::panel(ui, tl!("Community"), |ui| {
|
||||
@@ -286,13 +331,13 @@ impl LauncherUi {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
use artcraft_launcher_core::catalog::{GITHUB_URL, WEBSITE_URL};
|
||||
if widgets::button(ui, tl!("Join the Discord"), Kind::Primary, 0.0).on_hover_text(self.discord_url).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(self.discord_url));
|
||||
widgets::open_external_link(ui.ctx(), self.discord_url);
|
||||
}
|
||||
if widgets::button(ui, tl!("Website"), Kind::Secondary, 0.0).on_hover_text(WEBSITE_URL).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(WEBSITE_URL));
|
||||
widgets::open_external_link(ui.ctx(), WEBSITE_URL);
|
||||
}
|
||||
if widgets::button(ui, tl!("GitHub"), Kind::Secondary, 0.0).on_hover_text(GITHUB_URL).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(GITHUB_URL));
|
||||
widgets::open_external_link(ui.ctx(), GITHUB_URL);
|
||||
}
|
||||
// Wrap the social links as a group so their icons stay together.
|
||||
let width = widgets::icon_links_width(artcraft_launcher_core::catalog::SOCIAL.len(), 16.0);
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(crate) fn hero(ui: &mut Ui, icon: Option<ImageSource<'static>>, title: &str,
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(icon) = icon {
|
||||
// Decorative: the heading beside it carries the title.
|
||||
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(44.0, 44.0)));
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
@@ -99,3 +100,16 @@ pub(crate) fn panel(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui)) {
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
|
||||
/// [`panel`], but the body also receives the heading's id so its control can be named by that
|
||||
/// visible heading (egui 0.37's `Response::labelled_by`), without adding a second identical label.
|
||||
pub(crate) fn panel_with_heading(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui, egui::Id)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
Frame::new().stroke(t.hairline()).inner_margin(theme::BOX_MARGIN).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
let heading = widgets::hud(ui, title, t.faint);
|
||||
ui.add_space(8.0);
|
||||
body(ui, heading.id);
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
|
||||
+127
-2
@@ -296,11 +296,23 @@ type Face<'a> = (&'a str, &'static [u8], &'a [([u8; 4], f32)], FontFamily);
|
||||
/// `cjk`: the CJK language to draw, whose system font is added behind everything else, or `None`
|
||||
/// for a language that needs no CJK font.
|
||||
pub fn install_fonts(ctx: &egui::Context, cjk: Option<Lang>) {
|
||||
ctx.set_fonts(font_definitions(cjk));
|
||||
}
|
||||
|
||||
/// The font stack both the native and the web app use.
|
||||
///
|
||||
/// It starts from egui's `default_fonts` and enables `monochrome_emoji_fonts` at the workspace
|
||||
/// level, so arbitrary release-note, feed and alert text keeps the monochrome emoji coverage 0.36
|
||||
/// provided on every target, web included. The fixed gear and refresh controls stay painter-drawn
|
||||
/// [`crate::widgets::Glyph`]s rather than font glyphs. The one optional extra is a system CJK face,
|
||||
/// added only when the desktop selected a CJK language; neither a CJK face nor a system-font
|
||||
/// provider is bundled.
|
||||
fn font_definitions(cjk: Option<Lang>) -> FontDefinitions {
|
||||
const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
|
||||
const ARCHIVO: &[u8] = include_bytes!("../../../assets/fonts/Archivo-Variable.ttf");
|
||||
const GEIST_MONO: &[u8] = include_bytes!("../../../assets/fonts/GeistMono-Variable.ttf");
|
||||
let mut fonts = FontDefinitions::default();
|
||||
// egui's own fonts stay behind ours, for symbols and emoji ours don't have.
|
||||
// egui's own Latin faces and the small monochrome icon face stay behind ours.
|
||||
let fallback: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
||||
let faces: [Face<'_>; 7] = [
|
||||
("Inter", INTER, &[(*b"wght", 400.0), (*b"opsz", 14.0)], FontFamily::Proportional),
|
||||
@@ -330,12 +342,19 @@ pub fn install_fonts(ctx: &egui::Context, cjk: Option<Lang>) {
|
||||
stack.push("CJK".to_owned());
|
||||
}
|
||||
}
|
||||
ctx.set_fonts(fonts);
|
||||
fonts
|
||||
}
|
||||
|
||||
/// The system's font for a CJK language, read once per language (tens of MB). Desktop only: the
|
||||
/// web build has none, so CJK text can't be drawn there.
|
||||
pub fn cjk_font(lang: Lang) -> Option<Arc<FontData>> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let overridden = CJK_FONT_OVERRIDE.with(|slot| slot.borrow().as_ref().and_then(|overrides| overrides.get(lang.code()).cloned()));
|
||||
if let Some(font) = overridden {
|
||||
return font;
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
CJK_FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).entry(lang.code()).or_insert_with(|| load_cjk(lang)).clone()
|
||||
@@ -360,6 +379,52 @@ fn load_cjk(lang: Lang) -> Option<Arc<FontData>> {
|
||||
static CJK_FONTS: std::sync::Mutex<std::collections::BTreeMap<&str, Option<Arc<FontData>>>> =
|
||||
std::sync::Mutex::new(std::collections::BTreeMap::new());
|
||||
|
||||
#[cfg(test)]
|
||||
type CjkFontOverrides = std::collections::BTreeMap<&'static str, Option<Arc<FontData>>>;
|
||||
|
||||
// Test-only per-thread answer for cjk_font, scoped by CjkFontOverride.
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static CJK_FONT_OVERRIDE: std::cell::RefCell<Option<CjkFontOverrides>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Scopes a test-only answer for [`cjk_font`], restoring the previous answer on drop.
|
||||
#[cfg(test)]
|
||||
pub(crate) struct CjkFontOverride {
|
||||
previous: Option<CjkFontOverrides>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl CjkFontOverride {
|
||||
/// The system has a face for `lang`: a real bundled face with no CJK glyphs stands in, so a
|
||||
/// test can prove the font gate installed a face without faking CJK coverage or depending on
|
||||
/// the machine.
|
||||
pub(crate) fn available(lang: Lang) -> Self {
|
||||
const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
|
||||
Self::install(lang, Some(Arc::new(FontData::from_static(INTER))))
|
||||
}
|
||||
|
||||
/// The system has no face for `lang`.
|
||||
pub(crate) fn unavailable(lang: Lang) -> Self {
|
||||
Self::install(lang, None)
|
||||
}
|
||||
|
||||
fn install(lang: Lang, font: Option<Arc<FontData>>) -> Self {
|
||||
let mut overrides = std::collections::BTreeMap::new();
|
||||
overrides.insert(lang.code(), font);
|
||||
let previous = CJK_FONT_OVERRIDE.with(|slot| slot.borrow_mut().replace(overrides));
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for CjkFontOverride {
|
||||
fn drop(&mut self) {
|
||||
CJK_FONT_OVERRIDE.with(|slot| *slot.borrow_mut() = self.previous.take());
|
||||
}
|
||||
}
|
||||
|
||||
/// Has the system font for `lang` already been loaded? Never loads it.
|
||||
pub fn cjk_font_loaded(lang: Lang) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -377,3 +442,63 @@ pub fn cjk_font_loaded(lang: Lang) -> bool {
|
||||
pub fn cjk_font_available(lang: Lang) -> bool {
|
||||
cjk_font(lang).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The app's bundled font set is deliberate and bounded: our three families, egui's Latin
|
||||
/// fallbacks, its small monochrome icon face and the two attributed monochrome emoji faces.
|
||||
/// The same function is used by the desktop and web builds, so this upper bound holds for both.
|
||||
#[test]
|
||||
fn production_fonts_are_latin_plus_attributed_monochrome_fallbacks() {
|
||||
let fonts = font_definitions(None);
|
||||
for expected in ["Inter", "Inter-Medium", "Inter-SemiBold", "Archivo-Display", "GeistMono", "GeistMono-Medium", "GeistMono-Bold"] {
|
||||
assert!(fonts.font_data.contains_key(expected), "missing bundled face {expected}: {:?}", fonts.font_data.keys().collect::<Vec<_>>());
|
||||
}
|
||||
for fallback in ["Ubuntu-Light", "Hack", "egui-icons", "NotoEmoji-Regular", "emoji-icon-font"] {
|
||||
assert!(fonts.font_data.contains_key(fallback), "missing egui fallback {fallback}");
|
||||
}
|
||||
assert_eq!(fonts.font_data.len(), 12, "unexpected extra faces: {:?}", fonts.font_data.keys().collect::<Vec<_>>());
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace, medium(), semibold(), display(), mono(), mono_bold()] {
|
||||
let stack = fonts.families.get(&family).unwrap_or_else(|| panic!("family {family:?} missing"));
|
||||
assert_eq!(
|
||||
stack.last().map(String::as_str),
|
||||
Some("emoji-icon-font"),
|
||||
"{family:?} must end at the bundled monochrome emoji fallback, without system fonts: {stack:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Arbitrary network text must not depend on a system font: release notes, feed notices and
|
||||
/// alerts can contain emoji. This is a strict fixture over representative dynamic text, not
|
||||
/// just the fixed controls (whose gear/refresh glyphs are painter-drawn). It also pins the
|
||||
/// bundled font-data upper bound, so a stray system face cannot make coverage pass.
|
||||
#[test]
|
||||
fn bundled_font_stack_covers_representative_dynamic_release_and_feed_text() {
|
||||
use egui::{FontFamily, FontId};
|
||||
const DYNAMIC_TEXT: &str = concat!(
|
||||
"What's Changed: Faster exports 🚀 and fewer bugs 🐛 — New templates 🎨, ",
|
||||
"warnings ⚠ and a celebration 🎉 — ArtCraft news: ✨ ✅ ❌ 💡 🔒"
|
||||
);
|
||||
let fonts = font_definitions(None);
|
||||
assert_eq!(
|
||||
fonts.font_data.len(),
|
||||
12,
|
||||
"the coverage test is only meaningful for the bundled stack: {:?}",
|
||||
fonts.font_data.keys().collect::<Vec<_>>()
|
||||
);
|
||||
let ctx = egui::Context::default();
|
||||
install_fonts(&ctx, None);
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
for family in [FontFamily::Proportional, FontFamily::Monospace, medium(), semibold(), display(), mono(), mono_bold()] {
|
||||
ctx.fonts_mut(|fonts| {
|
||||
assert!(
|
||||
fonts.has_glyphs(&FontId::new(14.0, family.clone()), DYNAMIC_TEXT),
|
||||
"{family:?} lacks a glyph in the representative dynamic notes/feed fixture"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-25
@@ -83,7 +83,7 @@ impl LauncherUi {
|
||||
if bar == TitleBar::Custom {
|
||||
widgets::window_buttons(ui, h);
|
||||
}
|
||||
if widgets::nav_icon(ui, "⚙", tl!("Settings"), h, false).clicked() {
|
||||
if widgets::nav_icon(ui, widgets::Glyph::Settings, tl!("Settings"), h, false).clicked() {
|
||||
self.dialog = Some(Dialog::Settings);
|
||||
}
|
||||
// The controls reserve their actual widths before the secondary text gets space.
|
||||
@@ -159,7 +159,7 @@ impl LauncherUi {
|
||||
.on_hover_text(tl!("Stop waiting; the update can be started again later"))
|
||||
.clicked()
|
||||
{
|
||||
self.send(Command::Cancel { app: artcraft_launcher_core::catalog::LAUNCHER_ID.to_string() });
|
||||
self.act_command(Command::Cancel { app: artcraft_launcher_core::catalog::LAUNCHER_ID.to_string() });
|
||||
}
|
||||
widgets::progress(ui, fraction, &caption, t.accent, 220.0);
|
||||
return;
|
||||
@@ -172,13 +172,13 @@ impl LauncherUi {
|
||||
.on_hover_text(tl!("Downloads the new version, replaces this one and opens it again"))
|
||||
.clicked()
|
||||
{
|
||||
self.send(Command::UpdateLauncher);
|
||||
self.act_command(Command::UpdateLauncher);
|
||||
}
|
||||
} else {
|
||||
let url =
|
||||
self.platform.and_then(|p| launcher_download(r, p, crate::settings::is_flatpak_sandbox())).unwrap_or_else(|| r.html_url.clone());
|
||||
if widgets::button(ui, tl!("Download"), Kind::Primary, 0.0).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
widgets::open_external_link(ui.ctx(), &url);
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
@@ -215,9 +215,10 @@ impl LauncherUi {
|
||||
.hint_text(tl!("Search apps"))
|
||||
.desired_width(200.0)
|
||||
.min_size(vec2(0.0, theme::CONTROL_H))
|
||||
.vertical_align(egui::Align::Center)
|
||||
.align(egui::Align2::LEFT_CENTER)
|
||||
.margin(vec2(10.0, 0.0)),
|
||||
);
|
||||
)
|
||||
.accessible_name(tl!("Search apps"));
|
||||
if self.settings.view == ViewMode::Grid && !compact {
|
||||
self.sort_picker(ui);
|
||||
// The caption only where it fits beside the filters (longer in some languages).
|
||||
@@ -251,12 +252,16 @@ impl LauncherUi {
|
||||
});
|
||||
}
|
||||
|
||||
/// The toolbar's check button. It queues a forced check through the UI action boundary and is
|
||||
/// disabled while `self.checking`; the backend receives the check only when this pass flushes.
|
||||
fn refresh_button(&mut self, ui: &mut Ui) {
|
||||
if crate::bar::refresh_button(ui, tl!("Check for updates"), self.checking).clicked() {
|
||||
self.check_now();
|
||||
self.check_now_from_ui();
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid's sort combo box. It writes the chosen key and direction directly (not through the
|
||||
/// action queue) and carries an accessible "Sort" name.
|
||||
fn sort_picker(&mut self, ui: &mut Ui) {
|
||||
egui::ComboBox::from_id_salt("sort")
|
||||
.selected_text(RichText::new(grid_sort_label(grid_sort_now(&self.settings))).size(13.0))
|
||||
@@ -269,7 +274,9 @@ impl LauncherUi {
|
||||
self.settings.sort_descending = choice.1;
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.accessible_name(tl!("Sort"));
|
||||
}
|
||||
|
||||
/// Update every eligible app; used in the compact and full-width toolbars.
|
||||
@@ -278,7 +285,7 @@ impl LauncherUi {
|
||||
if widgets::button(ui, &label, Kind::Primary, 0.0).clicked() {
|
||||
let ids: Vec<&str> = self.entries.iter().filter(|e| self.in_filter(e, Filter::Updates) && e.job.is_none()).map(|e| e.app.id).collect();
|
||||
for id in ids {
|
||||
self.send(Command::Install { app: id.to_string() });
|
||||
self.act_command(Command::Install { app: id.to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -422,13 +429,17 @@ impl LauncherUi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws one app card into the caller-allocated `rect`. A card whose app has a details page gets
|
||||
/// a click target over the whole rect before its own links and buttons, so those sit on top and
|
||||
/// keep their clicks. An installed card is drawn in colour; a missing one greys out until the
|
||||
/// pointer is over it.
|
||||
fn card(&mut self, ui: &mut Ui, id: &str, rect: Rect) {
|
||||
let Some(entry) = self.entry(id).cloned() else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let app = entry.app;
|
||||
let hovered = ui.rect_contains_pointer(rect);
|
||||
let wipe = if self.reduce_motion { 0.0 } else { 0.3 };
|
||||
let lift = ui.ctx().animate_bool_with_time(ui.id().with(("card-hover", id)), hovered, wipe);
|
||||
let lift = ui.ctx().animate_bool_with_time(ui.scope_id().with(("card-hover", id)), hovered, wipe);
|
||||
// An app that is not installed is drawn in greys (icon, name, version, accent), and in its
|
||||
// colours while the pointer is over its card.
|
||||
let installed = entry.status(self.platform, self.caps.manage_apps).is_installed();
|
||||
@@ -438,8 +449,8 @@ impl LauncherUi {
|
||||
// The whole card opens the app's page. Added before the card's own links and buttons, so
|
||||
// they sit on top of it and keep their clicks.
|
||||
if AppDetails::by_id(id).is_some() {
|
||||
let open = ui.interact(rect, ui.id().with(("card-open", id)), Sense::click());
|
||||
open.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, fmt(tl!("{name} details"), &[("name", app.name)])));
|
||||
let open = ui.interact(rect, ui.scope_id().with(("card-open", id)), Sense::click());
|
||||
open.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, fmt(tl!("{name} details"), &[("name", app.name)])));
|
||||
if open.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
|
||||
self.detail = Some(id.to_string());
|
||||
}
|
||||
@@ -487,7 +498,7 @@ impl LauncherUi {
|
||||
// The slot is as tall as the text beside it when that is taller, so the two share a centre.
|
||||
let block = lines_height(ui, &[&name, &tagline], GAP);
|
||||
let (slot, icon_response) = ui.allocate_exact_size(vec2(ICON, block.max(ICON)), Sense::hover());
|
||||
icon_response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, fmt(tl!("{name} icon"), &[("name", app.name)])));
|
||||
icon_response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, fmt(tl!("{name} icon"), &[("name", app.name)])));
|
||||
// The icon rises a little on hover, over a soft drop shadow (the site's only shadow).
|
||||
let icon_rect = Rect::from_center_size(slot.center(), vec2(ICON, ICON)).translate(vec2(0.0, -4.0 * lift));
|
||||
ui.painter().add(
|
||||
@@ -626,7 +637,7 @@ impl LauncherUi {
|
||||
let arrow_color = if active { t.accent_ink } else { t.faint };
|
||||
Self::draw_sort_arrow(p, egui::pos2(arrow_x + 4.0, rect.center().y), down, arrow_color);
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
if resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { Some(k) } else { None }
|
||||
}
|
||||
|
||||
@@ -702,14 +713,15 @@ impl LauncherUi {
|
||||
}
|
||||
// Clicking the app's cell opens its page (the content is added after, so it wins any hit).
|
||||
if AppDetails::by_id(id).is_some() {
|
||||
let open = ui.interact(ui.max_rect(), ui.id().with(("row-open", id)), Sense::click());
|
||||
open.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, fmt(tl!("{name} details"), &[("name", entry.app.name)])));
|
||||
let open = ui.interact(ui.max_rect(), ui.scope_id().with(("row-open", id)), Sense::click());
|
||||
open.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, fmt(tl!("{name} details"), &[("name", entry.app.name)])));
|
||||
if open.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
|
||||
self.detail = Some(id.to_string());
|
||||
}
|
||||
}
|
||||
let installed = entry.status(self.platform, self.caps.manage_apps).is_installed();
|
||||
if let Some(icon) = crate::icons::app_image(ui.ctx(), entry.app.id, installed) {
|
||||
// Decorative: the wordmark beside it carries the app name.
|
||||
ui.add(icon.fit_to_exact_size(vec2(38.0, 38.0)));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
@@ -869,12 +881,12 @@ impl LauncherUi {
|
||||
// Waiting has one square button beside the bar, like the menu otherwise.
|
||||
widgets::progress(ui, fraction, &caption, color, if downloading { bar_w } else { main_w });
|
||||
if downloading && widgets::glyph_button(ui, widgets::Glyph::Pause, tl!("Pause download")).clicked() {
|
||||
self.send(Command::Pause { app: id.to_string() });
|
||||
self.act_command(Command::Pause { app: id.to_string() });
|
||||
}
|
||||
if (downloading || waiting)
|
||||
&& widgets::glyph_button(ui, widgets::Glyph::Close, if waiting { tl!("Cancel") } else { tl!("Cancel download") }).clicked()
|
||||
{
|
||||
self.send(Command::Cancel { app: id.to_string() });
|
||||
self.act_command(Command::Cancel { app: id.to_string() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,7 +918,7 @@ impl LauncherUi {
|
||||
self.install_app(id);
|
||||
}
|
||||
if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Discard download")).clicked() {
|
||||
self.send(Command::Cancel { app: id.to_string() });
|
||||
self.act_command(Command::Cancel { app: id.to_string() });
|
||||
}
|
||||
self.menu(ui, entry, status, links_in_menu);
|
||||
true
|
||||
@@ -942,7 +954,7 @@ impl LauncherUi {
|
||||
None => tl!("Download").into(),
|
||||
};
|
||||
if widgets::button(ui, &label, Kind::Primary, main_w).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
widgets::open_external_link(ui.ctx(), &url);
|
||||
}
|
||||
}
|
||||
Primary::Unavailable(label) => {
|
||||
@@ -951,11 +963,16 @@ impl LauncherUi {
|
||||
}
|
||||
}
|
||||
|
||||
/// The card's "…" menu. Backend commands are queued at the action boundary; dialog, settings and
|
||||
/// external-link actions happen inline. "Download again" appears
|
||||
/// only for a kept download in a ready available/update state and cancels before installing;
|
||||
/// `links` adds the website/repository/releases rows, which the details drawer omits because it
|
||||
/// shows them as buttons.
|
||||
fn menu(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, links: bool) {
|
||||
let id = entry.app.id.to_string();
|
||||
let manage = self.caps.manage_apps;
|
||||
let reveal = self.caps.reveal_label;
|
||||
widgets::more_menu(ui, |ui| {
|
||||
widgets::more_menu(ui, entry.app.name, |ui| {
|
||||
if entry.release().is_some() && ui.button(tl!("Release notes")).clicked() {
|
||||
self.dialog = Some(Dialog::Notes(id.clone()));
|
||||
ui.close();
|
||||
@@ -964,7 +981,7 @@ impl LauncherUi {
|
||||
let can_download_again =
|
||||
manage && entry.ready && entry.paused.is_none() && matches!(status, AppStatus::Available | AppStatus::UpdateAvailable);
|
||||
if can_download_again && ui.button(tl!("Download again")).clicked() {
|
||||
self.send(Command::Cancel { app: id.clone() });
|
||||
self.act_command(Command::Cancel { app: id.clone() });
|
||||
self.install_app(&id);
|
||||
ui.close();
|
||||
}
|
||||
@@ -979,7 +996,7 @@ impl LauncherUi {
|
||||
ui.separator();
|
||||
let link = |ui: &mut Ui, label: &str, url: String| {
|
||||
if ui.button(label).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
widgets::open_external_link(ui.ctx(), &url);
|
||||
ui.close();
|
||||
}
|
||||
};
|
||||
@@ -999,7 +1016,7 @@ impl LauncherUi {
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(t(reveal)).clicked() {
|
||||
self.send(Command::Reveal { app: id.to_string() });
|
||||
self.act_command(Command::Reveal { app: id.to_string() });
|
||||
ui.close();
|
||||
}
|
||||
if *status == AppStatus::UpToDate && ui.button(tl!("Reinstall")).clicked() {
|
||||
@@ -1031,7 +1048,7 @@ impl LauncherUi {
|
||||
ui.separator();
|
||||
for (label, url) in options {
|
||||
if ui.button(label).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
widgets::open_external_link(ui.ctx(), &url);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
+205
-42
@@ -47,7 +47,7 @@ pub fn hud(ui: &mut Ui, text: &str, color: Color32) -> Response {
|
||||
let galley = hud_galley(ui, text, color);
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::hover());
|
||||
paint_left(ui, rect, galley, color);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text));
|
||||
resp
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ pub fn hud_elided(ui: &mut Ui, text: &str, color: Color32, max_width: f32) -> Re
|
||||
let elided = galley.elided;
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::hover());
|
||||
paint_left(ui, rect, galley, color);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text));
|
||||
if elided { resp.on_hover_text(text) } else { resp }
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ pub fn tag(ui: &mut Ui, text: &str, fill: Color32, ink: Color32) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(16.0, 8.0), Sense::hover());
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, fill);
|
||||
paint_centred(ui, rect, galley, ink);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text));
|
||||
resp
|
||||
}
|
||||
|
||||
@@ -98,9 +98,9 @@ pub fn rule(ui: &mut Ui, height: f32) {
|
||||
ui.painter().vline(rect.center().x, rect.y_range(), t.hairline());
|
||||
}
|
||||
|
||||
/// A square header cell with a symbol and a hairline on its left; inverts on hover. While `busy`
|
||||
/// it shows a spinner and can't be clicked.
|
||||
pub fn nav_icon(ui: &mut Ui, symbol: &str, tip: &str, height: f32, busy: bool) -> Response {
|
||||
/// A square header cell with a drawn glyph and a hairline on its left; inverts on hover. While
|
||||
/// `busy` it shows a spinner and can't be clicked.
|
||||
pub fn nav_icon(ui: &mut Ui, glyph: Glyph, tip: &str, height: f32, busy: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(height, height), if busy { Sense::hover() } else { Sense::click() });
|
||||
let p = ui.painter();
|
||||
@@ -112,9 +112,9 @@ pub fn nav_icon(ui: &mut Ui, symbol: &str, tip: &str, height: f32, busy: bool) -
|
||||
if busy {
|
||||
egui::Spinner::new().size(16.0).color(t.muted).paint_at(ui, Rect::from_center_size(rect.center(), vec2(16.0, 16.0)));
|
||||
} else {
|
||||
p.text(rect.center(), egui::Align2::CENTER_CENTER, symbol, egui::FontId::proportional(17.0), if hovered { t.invert_fg } else { t.muted });
|
||||
paint_glyph(p, rect, glyph, if hovered { t.invert_fg } else { t.muted });
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, !busy, tip));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, !busy, tip));
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if busy { resp } else { resp.on_hover_cursor(egui::CursorIcon::PointingHand) }
|
||||
}
|
||||
@@ -137,7 +137,7 @@ pub fn title_bar_drag(ui: &mut Ui) {
|
||||
let r = ui.max_rect();
|
||||
let band = resize_band(ui.ctx());
|
||||
let rect = Rect::from_min_max(r.min + vec2(band, band), r.max - vec2(band, 0.0));
|
||||
let resp = ui.interact(rect, ui.id().with("title-bar"), Sense::click_and_drag());
|
||||
let resp = ui.interact(rect, ui.scope_id().with("title-bar"), Sense::click_and_drag());
|
||||
if resp.drag_started_by(egui::PointerButton::Primary) {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
@@ -174,6 +174,10 @@ pub fn window_buttons(ui: &mut Ui, height: f32) {
|
||||
rule(ui, height);
|
||||
}
|
||||
|
||||
/// Draws one caption button and returns its click response; the caller sends the viewport
|
||||
/// command. Hit-testing excludes the top resize band (and, for close, the right-edge band) so
|
||||
/// the window edges stay resizable; when maximized there is no band and close reaches the
|
||||
/// corner.
|
||||
fn window_button(ui: &mut Ui, button: WindowButton, height: f32) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(46.0, height), Sense::hover());
|
||||
@@ -182,7 +186,7 @@ fn window_button(ui: &mut Ui, button: WindowButton, height: f32) -> Response {
|
||||
let band = resize_band(ui.ctx());
|
||||
let right = if button == WindowButton::Close { band } else { 0.0 };
|
||||
let hit = Rect::from_min_max(rect.min + vec2(0.0, band), rect.max - vec2(right, 0.0));
|
||||
let resp = ui.interact(hit, ui.id().with(("window-button", button as u8)), Sense::click());
|
||||
let resp = ui.interact(hit, ui.scope_id().with(("window-button", button as u8)), Sense::click());
|
||||
let (fill, ink) = match (button, resp.hovered()) {
|
||||
(WindowButton::Close, true) => (Color32::from_rgb(0xc4, 0x2b, 0x1c), Color32::WHITE),
|
||||
(_, true) => (t.line, t.ink_strong),
|
||||
@@ -219,7 +223,7 @@ fn window_button(ui: &mut Ui, button: WindowButton, height: f32) -> Response {
|
||||
p.line_segment([c + vec2(-s, s), c + vec2(s, -s)], stroke);
|
||||
}
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
resp.on_hover_text(label)
|
||||
}
|
||||
|
||||
@@ -233,7 +237,7 @@ pub fn window_edges(ctx: &egui::Context) {
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let r = ctx.content_rect();
|
||||
ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("window-border"))).rect_stroke(
|
||||
ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("window-border"))).rect_stroke(
|
||||
r,
|
||||
CornerRadius::ZERO,
|
||||
t.hairline(),
|
||||
@@ -271,20 +275,37 @@ pub fn window_edges(ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `url` may be handed to the system/browser opener.
|
||||
///
|
||||
/// Release assets, release pages and feed links all come from the network, so every external
|
||||
/// opener goes through the same check as [`release::safe_web_link`](artcraft_launcher_core::release::safe_web_link).
|
||||
/// The one intentional non-https target is the exact Discord app deep link, which the desktop
|
||||
/// shell intercepts and opens through the system opener with a web fallback; arbitrary custom
|
||||
/// schemes stay refused.
|
||||
pub(crate) fn external_link_allowed(url: &str) -> bool {
|
||||
artcraft_launcher_core::release::safe_web_link(url) || url == artcraft_launcher_core::catalog::DISCORD_APP_URL
|
||||
}
|
||||
|
||||
/// Opens a UI link only after [`external_link_allowed`]; anything else is logged and dropped.
|
||||
pub(crate) fn open_external_link(ctx: &egui::Context, url: &str) {
|
||||
if external_link_allowed(url) {
|
||||
ctx.open_url(egui::OpenUrl::new_tab(url));
|
||||
} else {
|
||||
// Debug-format so control characters in a hostile URL cannot forge a log line.
|
||||
log::warn!("refusing to open unsafe URL: {url:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A mono link with an ↗: muted, ink on hover. Opens `url` in the browser.
|
||||
pub fn hud_link(ui: &mut Ui, text: &str, url: &str) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let galley = hud_galley(ui, &format!("{text} ↗"), t.muted);
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::click());
|
||||
paint_left(ui, rect, galley, if resp.hovered() { t.ink_strong } else { t.muted });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, text));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Link, true, text));
|
||||
let resp = resp.on_hover_text(url).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if resp.clicked() {
|
||||
if artcraft_launcher_core::release::safe_web_link(url) {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
} else {
|
||||
log::warn!("refusing to open unsafe URL: {url}");
|
||||
}
|
||||
open_external_link(ui.ctx(), url);
|
||||
}
|
||||
resp
|
||||
}
|
||||
@@ -296,7 +317,7 @@ pub fn badge(ui: &mut Ui, text: &str, color: Color32) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 12.0, 20.0), Sense::hover());
|
||||
ui.painter().rect(rect, CornerRadius::ZERO, t.sunken, t.hairline(), egui::StrokeKind::Inside);
|
||||
paint_centred(ui, rect, galley, color);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text));
|
||||
resp
|
||||
}
|
||||
|
||||
@@ -344,7 +365,7 @@ pub fn button(ui: &mut Ui, text: &str, kind: Kind, width: f32) -> Response {
|
||||
let fade = |c: Color32| if enabled { c } else { c.gamma_multiply(0.5) };
|
||||
ui.painter().rect(rect, CornerRadius::ZERO, fade(fill), Stroke::new(stroke.width, fade(stroke.color)), egui::StrokeKind::Inside);
|
||||
paint_centred(ui, rect, galley, fade(color));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, text));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, text));
|
||||
if enabled { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } else { resp }
|
||||
}
|
||||
|
||||
@@ -425,7 +446,7 @@ fn paint_cell<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, opt: &T, cell: &C
|
||||
Some((n, _)) => format!("{} {n}", cell.label),
|
||||
None => cell.label.to_string(),
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &name));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, &name));
|
||||
if resp.on_hover_text(cell.tip).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() && !on {
|
||||
*value = *opt;
|
||||
changed = true;
|
||||
@@ -459,8 +480,8 @@ pub fn progress(ui: &mut Ui, fraction: Option<f32>, caption: &str, color: Color3
|
||||
}
|
||||
}
|
||||
p.text(egui::pos2(rect.left(), rect.top() + 9.0), egui::Align2::LEFT_CENTER, caption, egui::FontId::new(12.0, theme::medium()), t.muted);
|
||||
let resp = ui.interact(rect, ui.id().with(("progress", caption)), Sense::hover());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ProgressIndicator, true, caption));
|
||||
let resp = ui.interact(rect, ui.scope_id().with(("progress", caption)), Sense::hover());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ProgressIndicator, true, caption));
|
||||
}
|
||||
|
||||
pub fn human_bytes(n: u64) -> String {
|
||||
@@ -480,6 +501,65 @@ pub fn human_bytes(n: u64) -> String {
|
||||
pub enum Glyph {
|
||||
Pause,
|
||||
Close,
|
||||
Settings,
|
||||
Refresh,
|
||||
}
|
||||
|
||||
/// Paints one of the launcher's symbols with the painter, centred in `rect`.
|
||||
///
|
||||
/// These are shapes rather than font characters: the bundled Inter, Archivo and Geist Mono faces
|
||||
/// have no gear or refresh symbol. The app enables egui's monochrome emoji fonts for arbitrary
|
||||
/// release-note/feed text, but these fixed controls stay painter-drawn so their geometry and
|
||||
/// accessible names do not depend on font selection.
|
||||
pub fn paint_glyph(p: &egui::Painter, rect: Rect, glyph: Glyph, color: Color32) {
|
||||
p.extend(glyph_shapes(rect, glyph, color));
|
||||
}
|
||||
|
||||
/// The shapes [`paint_glyph`] draws for `glyph`, centred in `rect`.
|
||||
///
|
||||
/// Separated from painting so a test can inspect the actual geometry: every shape must stay
|
||||
/// inside the button it belongs to, at both control sizes.
|
||||
fn glyph_shapes(rect: Rect, glyph: Glyph, color: Color32) -> Vec<egui::Shape> {
|
||||
let m = rect.center();
|
||||
match glyph {
|
||||
Glyph::Pause => [-3.0, 3.0]
|
||||
.into_iter()
|
||||
.map(|dx| egui::Shape::rect_filled(Rect::from_center_size(m + vec2(dx, 0.0), vec2(2.5, 10.0)), CornerRadius::ZERO, color))
|
||||
.collect(),
|
||||
Glyph::Close => {
|
||||
let s = 4.5;
|
||||
vec![
|
||||
egui::Shape::line_segment([m + vec2(-s, -s), m + vec2(s, s)], Stroke::new(1.5, color)),
|
||||
egui::Shape::line_segment([m + vec2(-s, s), m + vec2(s, -s)], Stroke::new(1.5, color)),
|
||||
]
|
||||
}
|
||||
Glyph::Settings => {
|
||||
let stroke = Stroke::new(1.5, color);
|
||||
let mut shapes = vec![egui::Shape::circle_stroke(m, 3.2, stroke)];
|
||||
for i in 0..8 {
|
||||
let angle = std::f32::consts::TAU * i as f32 / 8.0;
|
||||
let (sin, cos) = angle.sin_cos();
|
||||
shapes.push(egui::Shape::line_segment([m + vec2(cos, sin) * 4.8, m + vec2(cos, sin) * 6.6], stroke));
|
||||
}
|
||||
shapes
|
||||
}
|
||||
Glyph::Refresh => {
|
||||
let stroke = Stroke::new(1.5, color);
|
||||
let radius = 6.0;
|
||||
let start = -std::f32::consts::FRAC_PI_2;
|
||||
let points: Vec<egui::Pos2> = (0..=12)
|
||||
.map(|i| {
|
||||
let angle = start + std::f32::consts::TAU * 0.78 * i as f32 / 12.0;
|
||||
m + vec2(angle.cos(), angle.sin()) * radius
|
||||
})
|
||||
.collect();
|
||||
let tip = m + vec2(start.cos(), start.sin()) * radius;
|
||||
vec![
|
||||
egui::Shape::line(points, stroke),
|
||||
egui::Shape::convex_polygon(vec![tip + vec2(-2.4, -0.6), tip + vec2(1.6, -3.0), tip + vec2(1.2, 1.8)], color, Stroke::NONE),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A small outlined square button with a drawn glyph; inverts on hover.
|
||||
@@ -494,32 +574,22 @@ pub fn glyph_button(ui: &mut Ui, glyph: Glyph, tip: &str) -> Response {
|
||||
p.rect_stroke(rect, CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside);
|
||||
}
|
||||
let c = if hovered { t.invert_fg } else { t.muted };
|
||||
let m = rect.center();
|
||||
match glyph {
|
||||
Glyph::Pause => {
|
||||
for dx in [-3.0, 3.0] {
|
||||
p.rect_filled(Rect::from_center_size(m + vec2(dx, 0.0), vec2(2.5, 10.0)), CornerRadius::ZERO, c);
|
||||
}
|
||||
}
|
||||
Glyph::Close => {
|
||||
let s = 4.5;
|
||||
p.line_segment([m + vec2(-s, -s), m + vec2(s, s)], Stroke::new(1.5, c));
|
||||
p.line_segment([m + vec2(-s, s), m + vec2(s, -s)], Stroke::new(1.5, c));
|
||||
}
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
|
||||
paint_glyph(p, rect, glyph, c);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip));
|
||||
resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// The "…" button that opens an app's menu: an outlined square like [`glyph_button`].
|
||||
pub fn more_menu<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) {
|
||||
/// The "…" button that opens an app's menu: an outlined square like [`glyph_button`]. The visible
|
||||
/// dots are not a name a screen reader can use, so the button is named after the app.
|
||||
pub fn more_menu<R>(ui: &mut Ui, app_name: &str, add_contents: impl FnOnce(&mut Ui) -> R) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let btn =
|
||||
egui::Button::new(RichText::new("…").size(15.0).color(t.muted)).fill(Color32::TRANSPARENT).min_size(vec2(theme::CONTROL_H, theme::CONTROL_H));
|
||||
egui::containers::menu::MenuButton::from_button(btn).ui(ui, |ui| {
|
||||
let (response, _) = egui::containers::menu::MenuButton::from_button(btn).ui(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
add_contents(ui)
|
||||
});
|
||||
response.accessible_name(crate::i18n::fmt(tl!("More actions for {name}"), &[("name", app_name)]));
|
||||
}
|
||||
|
||||
/// ArtCraft's social accounts as a row of small monochrome icons (faint, brighter on hover).
|
||||
@@ -563,6 +633,9 @@ fn links_in_order<'l, 'a>(ui: &Ui, links: &'l [IconLink<'a>]) -> Vec<&'l IconLin
|
||||
if ui.layout().prefer_right_to_left() { links.iter().rev().collect() } else { links.iter().collect() }
|
||||
}
|
||||
|
||||
/// Draws one social logo as a link: `Role::Link` with its label and tip, a pointer cursor, and
|
||||
/// the safe external opener on click. An unknown social name draws nothing and returns without
|
||||
/// a response.
|
||||
fn icon_link(ui: &mut Ui, size: f32, link: &IconLink<'_>) {
|
||||
let Some(icon) = crate::icons::social(link.name) else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -570,10 +643,10 @@ fn icon_link(ui: &mut Ui, size: f32, link: &IconLink<'_>) {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(size + 2.0 * pad, size + 2.0 * pad), Sense::click());
|
||||
let color = if resp.hovered() { t.ink_strong } else { t.faint };
|
||||
egui::Image::new(icon).tint(color).paint_at(ui, rect.shrink(pad));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, &link.label));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Link, true, &link.label));
|
||||
let resp = resp.on_hover_text(&link.tip).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if resp.clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(link.url));
|
||||
open_external_link(ui.ctx(), link.url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,3 +675,93 @@ pub fn wordmark_in(ui: &mut Ui, app: &artcraft_launcher_core::CraftApp, size: f3
|
||||
pub fn heading(text: &str, size: f32, color: Color32) -> RichText {
|
||||
RichText::new(text).size(size).family(theme::display()).color(color).extra_letter_spacing(-0.02 * size)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The drawn glyphs replace the old `⚙`/`⟳` text (which 0.37 no longer has a fallback font
|
||||
/// for). Their geometry is production code: every shape must stay inside the control, at both
|
||||
/// the tool-button and header-cell sizes, or a click target would draw outside its hit rect.
|
||||
#[test]
|
||||
fn every_painter_glyph_stays_inside_its_control() {
|
||||
for glyph in [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh] {
|
||||
for side in [crate::theme::CONTROL_H, 48.0] {
|
||||
let rect = Rect::from_min_size(egui::pos2(100.0, 200.0), egui::vec2(side, side));
|
||||
let shapes = glyph_shapes(rect, glyph, Color32::WHITE);
|
||||
assert!(!shapes.is_empty(), "{glyph:?} draws nothing at {side} px");
|
||||
for shape in shapes {
|
||||
let bounds = shape.visual_bounding_rect();
|
||||
assert!(rect.expand(1.0).contains_rect(bounds), "{glyph:?} at {side} px draws outside its {rect:?}: {bounds:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The URLs the context has asked to open in its current output, for asserting that the opener
|
||||
/// was (or was not) reached.
|
||||
fn opened_urls(ctx: &egui::Context) -> Vec<String> {
|
||||
ctx.output(|out| {
|
||||
out.commands
|
||||
.iter()
|
||||
.filter_map(|command| match command {
|
||||
egui::OutputCommand::OpenUrl(open) => Some(open.url.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Every external opener goes through `open_external_link`; a release asset URL carrying an
|
||||
/// opener metacharacter must never reach egui's open command, while a plain https release
|
||||
/// URL and the exact Discord app deep link still do.
|
||||
#[test]
|
||||
fn external_openers_refuse_hostile_urls() {
|
||||
let ctx = egui::Context::default();
|
||||
let release_asset = "https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg";
|
||||
open_external_link(&ctx, release_asset);
|
||||
assert_eq!(opened_urls(&ctx), vec![release_asset.to_string()]);
|
||||
|
||||
ctx.output_mut(|out| out.commands.clear());
|
||||
for hostile in [
|
||||
"https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft.dmg|calc.exe",
|
||||
"https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft.dmg&whoami",
|
||||
"https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft.dmg`id`",
|
||||
"https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft.dmg\nnext",
|
||||
"https://user@evil.example/photocraft.dmg",
|
||||
"https:///path",
|
||||
"javascript:alert(1)",
|
||||
"file:///C:/Windows/System32/calc.exe",
|
||||
"ms-msdt:/id",
|
||||
"discord://-/invite/not-artcraft",
|
||||
] {
|
||||
open_external_link(&ctx, hostile);
|
||||
}
|
||||
assert!(opened_urls(&ctx).is_empty(), "hostile URLs reached the opener: {:?}", opened_urls(&ctx));
|
||||
|
||||
open_external_link(&ctx, artcraft_launcher_core::catalog::DISCORD_APP_URL);
|
||||
assert_eq!(opened_urls(&ctx), vec![artcraft_launcher_core::catalog::DISCORD_APP_URL.to_string()]);
|
||||
}
|
||||
|
||||
/// Each glyph must be distinct geometry, not the same fallback shape for all four.
|
||||
#[test]
|
||||
fn each_glyph_has_its_own_shapes() {
|
||||
let rect = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(crate::theme::CONTROL_H, crate::theme::CONTROL_H));
|
||||
let bounds: Vec<[u32; 4]> = [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh]
|
||||
.into_iter()
|
||||
.map(|glyph| {
|
||||
let union = glyph_shapes(rect, glyph, Color32::WHITE)
|
||||
.into_iter()
|
||||
.map(|shape| shape.visual_bounding_rect())
|
||||
.reduce(|a, b| a.union(b))
|
||||
.expect("every glyph draws at least one shape");
|
||||
[union.min.x.to_bits(), union.min.y.to_bits(), union.max.x.to_bits(), union.max.y.to_bits()]
|
||||
})
|
||||
.collect();
|
||||
for (i, a) in bounds.iter().enumerate() {
|
||||
for b in &bounds[i + 1..] {
|
||||
assert_ne!(a, b, "two glyphs share one bounding box");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
|
||||
use artcraft_launcher_ui_egui::settings::ViewMode;
|
||||
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
|
||||
const NOW: i64 = 1_791_500_000;
|
||||
|
||||
@@ -156,9 +156,38 @@ fn simplified_chinese_switches_persists_and_keeps_install_actions() {
|
||||
h.run_steps(2);
|
||||
// Opening Settings doesn't load the (large) Chinese font: the picker names it in English.
|
||||
assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded(artcraft_launcher_ui_egui::i18n::Lang::ZH_CN));
|
||||
// The picker sorts by native name, so 简体中文 comes last, below the fold of the popup's
|
||||
// scroll area: click it through AccessKit, which doesn't need it on screen.
|
||||
h.get_by_label("Simplified Chinese").click_accesskit();
|
||||
// The native-name sort puts Chinese last, below the popup's scroll viewport. Scroll the
|
||||
// actual popup with a wheel over its visible first row before the physical pointer click.
|
||||
let before_scroll = h.get_by_label("Simplified Chinese").rect();
|
||||
let first_row = h.get_by_label("Auto").rect();
|
||||
let popup_bottom = first_row.top() + h.ctx.global_style().spacing.combo_height;
|
||||
if std::env::var_os("ARTCRAFT_LAUNCHER_CHINESE_SCROLL_DIAGNOSTICS").is_some() {
|
||||
let node = h.get_by_label("Simplified Chinese");
|
||||
eprintln!("ScrollIntoView target {:?}: {node:?}", node.accesskit_node().locate());
|
||||
node.scroll_to_me();
|
||||
h.step();
|
||||
eprintln!("after action: rect {:?}, remaining input {:?}", h.get_by_label("Simplified Chinese").rect(), h.ctx.input(|i| i.events.clone()));
|
||||
h.step();
|
||||
eprintln!("after following pass: rect {:?}", h.get_by_label("Simplified Chinese").rect());
|
||||
}
|
||||
h.event(egui::Event::PointerMoved(first_row.center()));
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -600.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Wheel,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.run_steps(60);
|
||||
let after_scroll = h.get_by_label("Simplified Chinese").rect();
|
||||
assert!(after_scroll.center().y < before_scroll.center().y, "the last option must scroll into view: {before_scroll:?} -> {after_scroll:?}");
|
||||
assert!(
|
||||
(first_row.top()..=popup_bottom).contains(&after_scroll.center().y),
|
||||
"the physical click must be inside the popup viewport: {after_scroll:?}, top {}, bottom {popup_bottom}",
|
||||
first_row.top()
|
||||
);
|
||||
assert_eq!(h.state().as_ref().unwrap().settings.language, "en", "scrolling must not select a language");
|
||||
h.get_by_label("Simplified Chinese").click();
|
||||
h.run_steps(3);
|
||||
if !artcraft_launcher_ui_egui::theme::cjk_font_available(artcraft_launcher_ui_egui::i18n::Lang::ZH_CN) {
|
||||
// No Chinese font: choosing it keeps the UI in English, and Settings says why.
|
||||
|
||||
+519
-46
@@ -118,6 +118,27 @@ impl Backend for PlatformFake {
|
||||
struct NoHost;
|
||||
impl Host for NoHost {}
|
||||
|
||||
/// A harness that has the launcher's fonts, theme and image loaders before its first pass, and
|
||||
/// never paints a synthetic cursor into saved images.
|
||||
fn test_harness_builder<State>(theme: egui::Theme) -> egui_kittest::HarnessBuilder<State> {
|
||||
let choice = if theme == egui::Theme::Dark {
|
||||
artcraft_launcher_ui_egui::theme::ThemeChoice::Dark
|
||||
} else {
|
||||
artcraft_launcher_ui_egui::theme::ThemeChoice::Light
|
||||
};
|
||||
egui_kittest::HarnessBuilder::default()
|
||||
// 0.37 defaults: strict accessible names and a panic on a missing glyph. Neither is
|
||||
// relaxed here; fixed controls are painter-drawn and dynamic notes/feed text uses the
|
||||
// attributed monochrome emoji fonts.
|
||||
.with_accessibility_check(true)
|
||||
.with_render_cursor(false)
|
||||
.with_context_setup(move |ctx| {
|
||||
artcraft_launcher_ui_egui::theme::install_fonts(ctx, None);
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
artcraft_launcher_ui_egui::theme::apply(ctx, choice);
|
||||
})
|
||||
}
|
||||
|
||||
fn release(id: &str, v: &str, at: i64) -> Release {
|
||||
let name = format!("{id}-{v}-macos-universal.dmg");
|
||||
Release {
|
||||
@@ -235,6 +256,9 @@ fn harness_with_discord(discord_url: Option<&'static str>) -> (Harness<'static,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)] // Test fixture inputs vary independently across existing cases.
|
||||
/// Builds a harness with the full fixture matrix (events, view, theme, manage mode, scale, title
|
||||
/// bar, pictures, platform and Discord URL), forces English labels, and returns the recorded
|
||||
/// commands.
|
||||
fn harness_full(
|
||||
events: Vec<Event>,
|
||||
view: ViewMode,
|
||||
@@ -268,7 +292,7 @@ fn harness_full(
|
||||
let settings = Settings { view, notify: false, ..Settings::default() };
|
||||
// No `.wgpu()`: it gives every test its own GPU device, and those segfault on parallel test threads (see above).
|
||||
// The default renderer creates one only when a test renders.
|
||||
let harness = Harness::builder().with_size(egui::vec2(1180.0, 1200.0)).with_pixels_per_point(scale).with_theme(theme).build_ui_state(
|
||||
let harness = test_harness_builder(theme).with_size(egui::vec2(1180.0, 1200.0)).with_pixels_per_point(scale).with_theme(theme).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
@@ -291,12 +315,21 @@ fn harness_full(
|
||||
(harness, sent)
|
||||
}
|
||||
|
||||
/// Save a PNG under `ARTCRAFT_LAUNCHER_SNAPSHOTS` when that is set. `name` is a test-owned file
|
||||
/// stem (the test names and sizes make each one unique), never a path: a separator is rejected so
|
||||
/// no caller can write outside the evidence directory.
|
||||
fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
|
||||
let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };
|
||||
assert!(
|
||||
!name.is_empty() && !name.contains(['/', '\\']) && name != "." && name != "..",
|
||||
"snapshot name must be one pathless file stem, got {name:?}"
|
||||
);
|
||||
let img = h.render().unwrap();
|
||||
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
|
||||
}
|
||||
|
||||
/// A managing macOS fixture with a fresh PhotoCraft release and a scripted folder picker, for
|
||||
/// the install-folder flow.
|
||||
fn folder_harness(picked: Rc<RefCell<Option<String>>>) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||||
struct Picker(Rc<RefCell<Option<String>>>);
|
||||
impl Host for Picker {
|
||||
@@ -318,7 +351,7 @@ fn folder_harness(picked: Rc<RefCell<Option<String>>>) -> (Harness<'static, Opti
|
||||
clock: Rc::new(Cell::new(NOW)),
|
||||
});
|
||||
let mut host = Picker(picked);
|
||||
let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||||
let harness = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if let Some(backend) = backend.take() {
|
||||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(backend), Settings { notify: false, ..Default::default() }));
|
||||
@@ -338,6 +371,9 @@ fn install_commands(sent: &RefCell<Vec<Command>>) -> Vec<Command> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A custom-folder install opens a confirmation dialog with no command until Install; the
|
||||
/// chosen root and per-app folder are persisted, Configure precedes Install, and Cancel keeps
|
||||
/// no override.
|
||||
fn a_fresh_install_confirms_the_folder_and_configures_before_installing() {
|
||||
let picked = Rc::new(RefCell::new(None));
|
||||
let (mut h, sent) = folder_harness(picked.clone());
|
||||
@@ -359,6 +395,7 @@ fn a_fresh_install_confirms_the_folder_and_configures_before_installing() {
|
||||
h.run_steps(4);
|
||||
assert!(!sent.borrow().iter().any(|c| matches!(c, Command::Install { .. })));
|
||||
h.get_by_label("Apps are placed inside this folder.");
|
||||
h.check_accessibility();
|
||||
snapshot(&mut h, "install-folder-confirmation");
|
||||
*picked.borrow_mut() = Some("/Volumes/Photo".into());
|
||||
h.get_by_label("Browse…").click();
|
||||
@@ -446,6 +483,286 @@ fn windows_package_choice_can_select_portable_and_configures_the_backend() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The narrow 1000 x 700 native window that first showed it: a ComboBox grows to the width its
|
||||
/// layout offers, so an unconstrained one started at the label's last glyph and painted over
|
||||
/// "Install new apps as". The test reads the real widget rects, not a snapshot.
|
||||
#[test]
|
||||
fn a_narrow_settings_row_keeps_its_control_clear_of_its_label() {
|
||||
let (mut h, _sent) = harness_full(
|
||||
events(),
|
||||
ViewMode::Grid,
|
||||
egui::Theme::Light,
|
||||
true,
|
||||
1.0,
|
||||
artcraft_launcher_ui_egui::TitleBar::Native,
|
||||
Rc::default(),
|
||||
Some(Platform { os: Os::Windows, arch: Arch::X64 }),
|
||||
None,
|
||||
);
|
||||
h.set_size(egui::vec2(1000.0, 700.0));
|
||||
h.run_steps(6);
|
||||
h.get_by_label("Settings").click();
|
||||
h.run_steps(6);
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Label, "Install new apps as").scroll_to_me();
|
||||
h.run_steps(3);
|
||||
let label = h.get_by_role_and_label(egui::accesskit::Role::Label, "Install new apps as").rect();
|
||||
// Several combo boxes are on the settings page; the format chooser is the one nearest the
|
||||
// label, whichever column the dialog's size puts it in.
|
||||
let combo = h
|
||||
.query_all_by_role(egui::accesskit::Role::ComboBox)
|
||||
.min_by_key(|node| {
|
||||
let r = node.rect();
|
||||
((r.center().x - label.center().x).abs() + (r.center().y - label.center().y).abs()) as i32
|
||||
})
|
||||
.expect("the install-format chooser is on screen")
|
||||
.rect();
|
||||
assert!(!label.intersects(combo), "the label {label:?} overlaps its combo box {combo:?}");
|
||||
assert!(
|
||||
(label.center().y - combo.center().y).abs() < 2.0 && combo.left() >= label.right() + 4.0 || combo.top() >= label.bottom() + 4.0,
|
||||
"the label {label:?} and its combo box {combo:?} must keep a clear gap, on one row or stacked"
|
||||
);
|
||||
}
|
||||
|
||||
/// Drives the real launcher lifecycle (`logic` then `ui`) in one `Context::run_ui` pass, optionally
|
||||
/// asking egui for a discarded second pass over the same input.
|
||||
fn drive_launcher(launcher: &mut LauncherUi, ctx: &egui::Context, events: Vec<egui::Event>, time: f64, two_passes: bool) {
|
||||
let mut first = two_passes;
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1180.0, 1200.0))),
|
||||
time: Some(time),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let _ = ctx.run_ui(input, |root| {
|
||||
if first {
|
||||
first = false;
|
||||
root.ctx().request_discard("test: force a second pass");
|
||||
}
|
||||
launcher.logic(&root.ctx().clone(), &mut NoHost);
|
||||
launcher.ui(root, &mut NoHost);
|
||||
});
|
||||
}
|
||||
|
||||
/// Drives one pointer gesture as move, press and release over separate frames, optionally
|
||||
/// asking for a discarded second pass on release.
|
||||
fn click_steps(launcher: &mut LauncherUi, ctx: &egui::Context, at: egui::Pos2, time: f64, two_passes_on_release: bool) {
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
drive_launcher(launcher, ctx, vec![egui::Event::PointerMoved(at)], time, false);
|
||||
drive_launcher(launcher, ctx, vec![button(true)], time + 0.1, false);
|
||||
drive_launcher(launcher, ctx, vec![button(false)], time + 0.2, two_passes_on_release);
|
||||
}
|
||||
|
||||
/// Counts the `Install` commands among the recorded backend commands.
|
||||
fn install_count(sent: &RefCell<Vec<Command>>) -> usize {
|
||||
sent.borrow().iter().filter(|c| matches!(c, Command::Install { .. })).count()
|
||||
}
|
||||
|
||||
/// The AccessKit target of a visible widget, as an assistive technology would address it.
|
||||
fn accesskit_target(h: &Harness<'_, Option<LauncherUi>>, label: &str) -> (egui::accesskit::NodeId, egui::accesskit::TreeId) {
|
||||
h.get_by_label(label).accesskit_node().locate()
|
||||
}
|
||||
|
||||
/// The semantic click a screen reader emits for `target`, addressed exactly as the tree did.
|
||||
fn accesskit_click(target: (egui::accesskit::NodeId, egui::accesskit::TreeId)) -> egui::Event {
|
||||
egui::Event::AccessKitActionRequest(egui::accesskit::ActionRequest {
|
||||
target_node: target.0,
|
||||
target_tree: target.1,
|
||||
action: egui::accesskit::Action::Click,
|
||||
data: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs one visible frame with a semantic (AccessKit) click delivered on every egui pass, or over
|
||||
/// one pass only when `two_passes` is false. This is the actual UI path: delivering the same click
|
||||
/// on a discarded pass lets a button's own `clicked()` handler queue its action twice, so a call
|
||||
/// site that bypasses `act_command`/`act` is caught instead of being hidden by egui's
|
||||
/// first-pass-only pointer events.
|
||||
///
|
||||
/// An AccessKit request belongs to the `RawInput` of a pass, exactly as kittest queues it;
|
||||
/// inserting it with `Context::input_mut` after `begin_pass` is too late in 0.37. `run_ui` also
|
||||
/// spends the raw events on its first pass and re-runs the second pass without them, so the passes
|
||||
/// are driven explicitly. They share one `cumulative_frame_nr` (the action boundary's visible-frame
|
||||
/// identity): `run_ui` advances that counter once per visible frame, while `begin_pass`/`end_pass`
|
||||
/// do not. The empty priming frame starts a fresh frame without drawing anything.
|
||||
fn drive_accesskit_click(
|
||||
launcher: &mut LauncherUi,
|
||||
ctx: &egui::Context,
|
||||
target: (egui::accesskit::NodeId, egui::accesskit::TreeId),
|
||||
time: f64,
|
||||
two_passes: bool,
|
||||
) {
|
||||
let screen_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1180.0, 1200.0));
|
||||
let _ = ctx.run_ui(egui::RawInput { screen_rect: Some(screen_rect), time: Some(time), ..Default::default() }, |_| {});
|
||||
let passes = if two_passes { 2 } else { 1 };
|
||||
for pass in 0..passes {
|
||||
ctx.begin_pass(egui::RawInput {
|
||||
screen_rect: Some(screen_rect),
|
||||
time: Some(time),
|
||||
events: vec![accesskit_click(target)],
|
||||
..Default::default()
|
||||
});
|
||||
ctx.root_ui(|root| {
|
||||
if two_passes && pass == 0 {
|
||||
root.ctx().request_discard("test: repeat the semantic click in this frame");
|
||||
}
|
||||
// Match Harness::build_ui_state's scope and frame: auto widget IDs include both.
|
||||
// Drawing directly in root_ui addresses different nodes than the captured tree.
|
||||
root.scope_builder(egui::UiBuilder::new(), |ui| {
|
||||
egui::Frame::central_panel(ui.style()).outer_margin(8.0).inner_margin(0.0).show(ui, |ui| {
|
||||
launcher.logic(&ui.ctx().clone(), &mut NoHost);
|
||||
launcher.ui(ui, &mut NoHost);
|
||||
});
|
||||
});
|
||||
});
|
||||
let _ = ctx.end_pass();
|
||||
}
|
||||
}
|
||||
|
||||
/// The end-to-end shape of one pointer gesture per frame. A discarded second pass has no pointer
|
||||
/// events (egui clears them after the first pass), so this alone cannot catch a call site that
|
||||
/// bypasses the action boundary. The AccessKit-click tests below re-deliver the same semantic click
|
||||
/// on every pass and therefore exercise the actual UI call sites twice in one visible frame.
|
||||
#[test]
|
||||
fn the_same_download_is_sent_once_across_discarded_passes_but_again_next_frame() {
|
||||
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(8);
|
||||
let at = h.get_by_label("Update to 0.5.0").rect().center();
|
||||
let ctx = h.ctx.clone();
|
||||
let mut launcher = h.state_mut().take().expect("the harness built the launcher");
|
||||
|
||||
sent.borrow_mut().clear();
|
||||
click_steps(&mut launcher, &ctx, at, 100.0, true);
|
||||
assert_eq!(install_count(&sent), 1, "one visible frame must send one install, not one per pass: {:?}", sent.borrow());
|
||||
|
||||
// A new frame is a new user gesture: the guard from the previous frame must not swallow it.
|
||||
sent.borrow_mut().clear();
|
||||
click_steps(&mut launcher, &ctx, at, 200.0, false);
|
||||
assert_eq!(install_count(&sent), 1, "the next frame must accept the same action again: {:?}", sent.borrow());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Clicking Open across a discarded second pass increments the launch counter once for the
|
||||
/// visible frame.
|
||||
fn opening_an_app_counts_once_across_discarded_passes() {
|
||||
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(8);
|
||||
let at = h.get_all_by_label("Open").next().expect("an up-to-date app has an Open button").rect().center();
|
||||
let ctx = h.ctx.clone();
|
||||
let mut launcher = h.state_mut().take().expect("the harness built the launcher");
|
||||
let before = launcher.settings.launches.values().copied().sum::<u32>();
|
||||
click_steps(&mut launcher, &ctx, at, 100.0, true);
|
||||
let after = launcher.settings.launches.values().copied().sum::<u32>();
|
||||
assert_eq!(after, before + 1, "one launch gesture increments the count once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The real Update button, clicked through AccessKit on every pass, crosses the action boundary
|
||||
/// once per frame and again on the next frame.
|
||||
fn the_actual_update_button_queues_once_across_a_discarded_pass() {
|
||||
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(8);
|
||||
let target = accesskit_target(&h, "Update to 0.5.0");
|
||||
let ctx = h.ctx.clone();
|
||||
let mut launcher = h.state_mut().take().expect("the harness built the launcher");
|
||||
sent.borrow_mut().clear();
|
||||
drive_accesskit_click(&mut launcher, &ctx, target, 100.0, true);
|
||||
assert_eq!(install_count(&sent), 1, "the real Update button must cross the action boundary once: {:?}", sent.borrow());
|
||||
// The next visible frame is a new gesture, not a repeated pass.
|
||||
sent.borrow_mut().clear();
|
||||
drive_accesskit_click(&mut launcher, &ctx, target, 200.0, false);
|
||||
assert_eq!(install_count(&sent), 1, "the next frame must accept the same action again: {:?}", sent.borrow());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The real Open button, clicked through AccessKit on every pass, delivers one `Launch` and
|
||||
/// increments the count once.
|
||||
fn the_actual_open_button_launches_once_across_a_discarded_pass() {
|
||||
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(8);
|
||||
let target = h.get_all_by_label("Open").next().expect("an up-to-date app has an Open button").accesskit_node().locate();
|
||||
let ctx = h.ctx.clone();
|
||||
let mut launcher = h.state_mut().take().expect("the harness built the launcher");
|
||||
let before = launcher.settings.launches.values().copied().sum::<u32>();
|
||||
sent.borrow_mut().clear();
|
||||
drive_accesskit_click(&mut launcher, &ctx, target, 100.0, true);
|
||||
let launches = sent.borrow().iter().filter(|c| matches!(c, Command::Launch { .. })).count();
|
||||
assert_eq!(launches, 1, "the real Open button must deliver one launch, not one per pass: {:?}", sent.borrow());
|
||||
assert_eq!(launcher.settings.launches.values().copied().sum::<u32>(), before + 1, "the launch count increments once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A toast created by logic while no window ran is shown once on the first drawing pass and
|
||||
/// expires relative to that pass.
|
||||
fn a_job_finished_while_hidden_shows_one_fresh_toast_on_the_next_frame() {
|
||||
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let backend = Fake {
|
||||
events: vec![Event::JobFinished { app: "photocraft".into(), result: Ok("PhotoCraft updated to 0.5.0".into()) }],
|
||||
sent: sent.clone(),
|
||||
manage: true,
|
||||
pictures: Rc::default(),
|
||||
asked: Vec::new(),
|
||||
clock: Rc::new(Cell::new(NOW)),
|
||||
};
|
||||
// Build the harness with no launcher first: its construction pass runs at time 0, before a test
|
||||
// can set `RawInput::time`, and would otherwise consume the hidden message.
|
||||
let mut attached = false;
|
||||
// Ask for a discarded second pass on the first drawing frame: the toast path must be
|
||||
// idempotent across passes, not just across steps.
|
||||
let mut discard_once = true;
|
||||
let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if let Some(l) = state {
|
||||
if !attached {
|
||||
attached = true;
|
||||
l.attach(ui.ctx());
|
||||
}
|
||||
if discard_once {
|
||||
discard_once = false;
|
||||
ui.ctx().request_discard("test: discard the first drawing pass");
|
||||
}
|
||||
l.logic(&ui.ctx().clone(), &mut NoHost);
|
||||
l.ui(ui, &mut NoHost);
|
||||
}
|
||||
},
|
||||
None,
|
||||
);
|
||||
// The tray path: `logic` runs without any UI pass, with the input time of the last shown frame.
|
||||
let windowless = egui::Context::default();
|
||||
let mut launcher = LauncherUi::new(&windowless, Box::new(backend), Settings { notify: false, language: "en".into(), ..Settings::default() });
|
||||
launcher.logic(&windowless, &mut NoHost);
|
||||
*h.state_mut() = Some(launcher);
|
||||
// The visible window's clock is far ahead of the logic-only tick's stale input time. The toast
|
||||
// must be fresh on the first drawing pass and must expire relative to it.
|
||||
h.input_mut().time = Some(1000.0);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.query_all_by_label("PhotoCraft updated to 0.5.0").count(), 1, "the hidden result is shown once");
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.query_all_by_label("PhotoCraft updated to 0.5.0").count(), 1, "a discarded/next pass must not duplicate it");
|
||||
// A stale expiry (old logic time + 4.5 s) would already have dropped it; at 1004.6 the real
|
||||
// 1000.0 + 4.5 deadline has passed, so the toast goes.
|
||||
h.input_mut().time = Some(1004.6);
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("PhotoCraft updated to 0.5.0").is_none(), "the toast expires relative to the drawing pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// At 560 px the toolbar's search, refresh, settings, sort and per-app menu keep their
|
||||
/// accessible names, with no unnamed input widgets and a strict accessibility pass.
|
||||
fn a_narrow_toolbar_names_its_controls_and_passes_strict_accessibility() {
|
||||
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.set_size(egui::vec2(560.0, 900.0));
|
||||
h.run_steps(6);
|
||||
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search apps");
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, "Check for updates");
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, "Settings");
|
||||
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Sort");
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, "More actions for PhotoCraft");
|
||||
let unnamed = h.unnamed_widgets();
|
||||
assert!(unnamed.is_empty(), "narrow toolbar has unnamed input widgets: {unnamed:?}");
|
||||
h.check_accessibility();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_locations_survive_settings_save_and_older_settings_use_system_default() {
|
||||
let old: Settings = serde_json::from_str(r#"{"auto_update":true}"#).unwrap();
|
||||
@@ -460,6 +777,8 @@ fn install_locations_survive_settings_save_and_older_settings_use_system_default
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Logic without a window still consumes backend events; attaching to a new window then draws
|
||||
/// the update cards.
|
||||
fn keeps_working_with_no_window_then_draws_in_a_new_one() {
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let backend =
|
||||
@@ -473,7 +792,7 @@ fn keeps_working_with_no_window_then_draws_in_a_new_one() {
|
||||
assert_eq!(ui.settings.last_check, Some(NOW - 180));
|
||||
|
||||
let mut attached = false;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
move |egui_ui, state: &mut LauncherUi| {
|
||||
if !attached {
|
||||
attached = true;
|
||||
@@ -545,6 +864,8 @@ fn downloads_can_be_paused_resumed_and_discarded() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A kept download offers Retry install (which only opens install) and menu Download again
|
||||
/// (Cancel then Install); apps without a kept download do not offer it.
|
||||
fn a_kept_download_offers_retry_install_and_download_again() {
|
||||
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(4);
|
||||
@@ -568,7 +889,7 @@ fn a_kept_download_offers_retry_install_and_download_again() {
|
||||
sent.borrow_mut().clear();
|
||||
// The … menu beside it.
|
||||
let beside = |r: egui::Rect| row.y_range().contains(r.center().y) && (row.right()..row.right() + 60.0).contains(&r.left());
|
||||
h.get_all_by_label("…").find(|n| beside(n.rect())).unwrap().click();
|
||||
h.get_all_by_label_contains("More actions").find(|n| beside(n.rect())).unwrap().click();
|
||||
h.run_steps(3);
|
||||
snapshot(&mut h, "grid-retry-install-menu-dark");
|
||||
h.get_by_label("Download again").click();
|
||||
@@ -599,6 +920,8 @@ fn a_dismissed_install_prompt_is_not_called_a_cancelled_download() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A ready update names its version and installs on Retry, while an up-to-date app offers
|
||||
/// Reinstall instead of Download again.
|
||||
fn a_kept_update_names_its_version_and_an_up_to_date_app_is_not_downloaded_again() {
|
||||
let mut ev = events();
|
||||
for app in ["effectcraft", "wordcraft"] {
|
||||
@@ -613,10 +936,11 @@ fn a_kept_update_names_its_version_and_an_up_to_date_app_is_not_downloaded_again
|
||||
h.run_steps(2);
|
||||
assert_eq!(*sent.borrow(), [Command::Install { app: "effectcraft".into() }]);
|
||||
// WordCraft is up to date: its menu offers Reinstall, which uses the kept file.
|
||||
let row = h.get_all_by_label("WordCraft").next().unwrap().rect().center().y;
|
||||
h.get_all_by_label("…").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
|
||||
let row = h.get_by_role_and_label(egui::accesskit::Role::Label, "WordCraft").rect().center().y;
|
||||
h.get_all_by_label_contains("More actions").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Reinstall");
|
||||
h.check_accessibility();
|
||||
assert!(h.query_by_label("Download again").is_none());
|
||||
}
|
||||
|
||||
@@ -653,6 +977,23 @@ fn the_launcher_update_waiting_for_other_installs_can_be_cancelled() {
|
||||
assert_eq!(*sent.borrow(), [Command::Cancel { app: "artcraft-launcher".into() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The launcher banner's Cancel crosses the action boundary once per visible frame.
|
||||
fn the_launcher_banner_cancel_is_sent_once_across_discarded_passes() {
|
||||
let mut ev = events();
|
||||
ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW)));
|
||||
ev.push(Event::Progress { app: "artcraft-launcher".into(), phase: JobPhase::Waiting });
|
||||
let (mut h, sent) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0);
|
||||
h.run_steps(6);
|
||||
let at = h.get_by_label("Cancel update").rect().center();
|
||||
let ctx = h.ctx.clone();
|
||||
let mut launcher = h.state_mut().take().expect("the harness built the launcher");
|
||||
sent.borrow_mut().clear();
|
||||
click_steps(&mut launcher, &ctx, at, 100.0, true);
|
||||
let cancels = sent.borrow().iter().filter(|c| matches!(c, Command::Cancel { .. })).count();
|
||||
assert_eq!(cancels, 1, "the banner's Cancel must cross the action boundary once: {:?}", sent.borrow());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_cancellation_says_what_it_called_off() {
|
||||
let finished = |app: &str, why: &str| Event::JobFinished { app: app.into(), result: Err(why.into()) };
|
||||
@@ -688,45 +1029,56 @@ fn table_view_and_light_theme() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Hide removes an app from All into Hidden Apps; Unhide restores it, clears the hidden set and
|
||||
/// returns the filter to All.
|
||||
fn hiding_an_app_removes_it_until_it_is_unhidden() {
|
||||
let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);
|
||||
h.run_steps(6);
|
||||
let row = h.get_all_by_label("PhotoCraft").next().unwrap().rect().center().y;
|
||||
h.get_all_by_label("…").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
|
||||
let row = h.get_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft").rect().center().y;
|
||||
h.get_all_by_label_contains("More actions").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Hide App").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.query_by_label("PhotoCraft").is_none());
|
||||
assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft").is_none());
|
||||
assert!(h.state().as_ref().unwrap().settings.hidden.contains("photocraft"));
|
||||
h.get_by_label("Hidden Apps 1").click();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("PhotoCraft");
|
||||
assert!(h.query_by_label("WordCraft").is_none());
|
||||
let row = h.get_all_by_label("PhotoCraft").next().unwrap().rect().center().y;
|
||||
h.get_all_by_label("…").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft");
|
||||
assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "WordCraft").is_none());
|
||||
let row = h.get_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft").rect().center().y;
|
||||
h.get_all_by_label_contains("More actions").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Unhide App").click();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("PhotoCraft");
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft");
|
||||
assert!(h.query_by_label("Hidden Apps 1").is_none());
|
||||
assert!(h.state().as_ref().unwrap().settings.hidden.is_empty());
|
||||
assert_eq!(h.state().as_ref().unwrap().settings.filter, artcraft_launcher_ui_egui::settings::Filter::All);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The view switch stores Table, and the table names each app once in its wordmark rather than
|
||||
/// again on the decorative icon.
|
||||
fn switching_views() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(6);
|
||||
h.get_by_label("Show apps as a table").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().as_ref().unwrap().settings.view, ViewMode::Table);
|
||||
h.get_by_label("PhotoCraft");
|
||||
h.get_by_label("WordCraft");
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft");
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Label, "WordCraft");
|
||||
// The row icon is decorative; the wordmark carries the name exactly once.
|
||||
assert_eq!(h.query_all_by_role_and_label(egui::accesskit::Role::Image, "PhotoCraft").count(), 0, "the table icon must not repeat the wordmark");
|
||||
assert_eq!(
|
||||
h.query_all_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft").count(),
|
||||
1,
|
||||
"the table wordmark names PhotoCraft exactly once"
|
||||
);
|
||||
}
|
||||
|
||||
/// How far the middle of these lines of text is below the middle of the app icon beside them, in points.
|
||||
fn below_icon_centre(h: &Harness<'_, Option<LauncherUi>>, lines: &[&str]) -> f32 {
|
||||
let text = lines.iter().map(|l| h.get_by_label(l).rect()).reduce(|a, b| a.union(b)).unwrap();
|
||||
let text = lines.iter().map(|l| h.get_by_role_and_label(egui::accesskit::Role::Label, l).rect()).reduce(|a, b| a.union(b)).unwrap();
|
||||
let icon = h
|
||||
.query_all(egui_kittest::kittest::by().role(egui::accesskit::Role::Image))
|
||||
.map(|n| n.rect())
|
||||
@@ -825,11 +1177,19 @@ fn the_first_close_to_the_tray_is_explained_once() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The Settings dialog announces its heading once and shows the community section's Discord
|
||||
/// entry alongside the footer's.
|
||||
fn settings_link_to_discord() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(6);
|
||||
h.get_by_label("Settings").click();
|
||||
h.run_steps(4);
|
||||
h.check_accessibility();
|
||||
assert_eq!(
|
||||
h.query_all_by_role_and_label(egui::accesskit::Role::Label, "Settings").count(),
|
||||
1,
|
||||
"the Settings hero icon must not repeat its heading"
|
||||
);
|
||||
snapshot(&mut h, "settings-dark");
|
||||
h.get_by_label("Questions, ideas and news from the ArtCraft team");
|
||||
// The footer icon and the button in Settings.
|
||||
@@ -837,6 +1197,8 @@ fn settings_link_to_discord() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// After the taller Settings dialog, the uninstall prompt keeps its own content-sized modal with
|
||||
/// its buttons close under the text (#88), and Cancel closes it.
|
||||
fn the_uninstall_prompt_keeps_its_own_size_after_a_taller_dialog() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(6);
|
||||
@@ -846,10 +1208,13 @@ fn the_uninstall_prompt_keeps_its_own_size_after_a_taller_dialog() {
|
||||
h.get_by_label("Done").click();
|
||||
h.run_steps(3);
|
||||
// PhotoCraft is installed, so its … menu offers Uninstall.
|
||||
h.get_all_by_label("…").next().unwrap().click();
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, "More actions for PhotoCraft").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Uninstall…").click();
|
||||
h.run_steps(4);
|
||||
h.check_accessibility();
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Label, "Uninstall PhotoCraft?");
|
||||
assert_eq!(h.query_all_by_label_contains("Uninstall PhotoCraft?").count(), 1, "the title icon is decorative, not a second copy of the heading");
|
||||
snapshot(&mut h, "uninstall-prompt-dark");
|
||||
// The buttons follow the text closely, not somewhere down the height Settings had (#88).
|
||||
let text = h.get_by_label("The app will be moved to the Trash. Your documents and settings stay.").rect();
|
||||
@@ -862,6 +1227,8 @@ fn the_uninstall_prompt_keeps_its_own_size_after_a_taller_dialog() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The footer links website and GitHub, cards link only GitHub, and the table menu adds the
|
||||
/// app's website and repository.
|
||||
fn links_to_the_website_github_and_each_apps_pages() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(6);
|
||||
@@ -877,7 +1244,7 @@ fn links_to_the_website_github_and_each_apps_pages() {
|
||||
// The table's … menu has the app's website and GitHub repo.
|
||||
let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);
|
||||
h.run_steps(6);
|
||||
h.get_all_by_label("…").nth(8).unwrap().click(); // WordCraft
|
||||
h.get_all_by_label_contains("More actions").nth(8).unwrap().click(); // WordCraft
|
||||
h.run_steps(3);
|
||||
h.get_by_label("GitHub repository");
|
||||
// The footer's Website link and WordCraft's now-live one in the menu.
|
||||
@@ -911,6 +1278,8 @@ fn all_opened(h: &mut Harness<'_, Option<LauncherUi>>, label: &str) -> Vec<Strin
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Footer, card and social links open their expected pages; one card menu's All releases action
|
||||
/// opens a repository releases page.
|
||||
fn links_open_the_right_pages() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(6);
|
||||
@@ -929,13 +1298,45 @@ fn links_open_the_right_pages() {
|
||||
assert_eq!(click_opens(&mut h, "Instagram").as_deref(), Some("https://www.instagram.com/officialartcraftstudios"));
|
||||
assert_eq!(click_opens(&mut h, "LinkedIn").as_deref(), Some("https://www.linkedin.com/company/artcraft-ai"));
|
||||
// The … menu.
|
||||
h.get_all_by_label("…").next().unwrap().click();
|
||||
h.get_all_by_label_contains("More actions").next().unwrap().click();
|
||||
h.run_steps(3);
|
||||
// The first card is the first installed app by name.
|
||||
let url = click_opens(&mut h, "All releases").unwrap_or_default();
|
||||
assert!(url.starts_with("https://github.com/storytold/") && url.ends_with("/releases"), "{url}");
|
||||
}
|
||||
|
||||
/// An asset URL can come straight from GitHub's release JSON; it must not reach the opener when
|
||||
/// it carries an opener metacharacter, whether it is shown on a card or in the launcher dialog.
|
||||
#[test]
|
||||
fn hostile_release_asset_urls_never_reach_the_opener() {
|
||||
const HOSTILE: &str = "https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg|calc.exe";
|
||||
|
||||
let mut ev = events();
|
||||
for event in &mut ev {
|
||||
if let Event::Latest { app, latest: LatestRelease::Found(release), .. } = event
|
||||
&& app.as_str() == "photocraft"
|
||||
&& let Some(asset) = release.assets.first_mut()
|
||||
{
|
||||
asset.url = HOSTILE.to_string();
|
||||
}
|
||||
}
|
||||
let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, false, 1.0);
|
||||
h.run_steps(8);
|
||||
assert!(h.query_all_by_label("Download for macOS").count() >= 8, "the cards must offer their asset downloads");
|
||||
let opened = all_opened(&mut h, "Download for macOS");
|
||||
assert!(!opened.iter().any(|url| url.as_str() == HOSTILE), "a hostile card asset URL reached the opener: {opened:?}");
|
||||
|
||||
let mut ev = events();
|
||||
let mut launcher_release = release("artcraft-launcher", "0.2.0", NOW);
|
||||
launcher_release.assets[0].url = HOSTILE.to_string();
|
||||
ev.push(Event::LauncherUpdate(launcher_release));
|
||||
let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, false, 1.0);
|
||||
h.run_steps(6);
|
||||
assert_eq!(h.query_all_by_label("Download").count(), 1, "the launcher update dialog must be open");
|
||||
let opened = all_opened(&mut h, "Download");
|
||||
assert!(opened.is_empty(), "a hostile launcher-dialog asset URL reached the opener: {opened:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discord_link_opens_app_when_discord_is_installed() {
|
||||
let (mut h, _) = harness_with_discord(Some(artcraft_launcher_core::catalog::DISCORD_APP_URL));
|
||||
@@ -944,12 +1345,14 @@ fn discord_link_opens_app_when_discord_is_installed() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Background detection waits five minutes: it is not sent at startup or on every frame, and is
|
||||
/// sent once at the five-minute mark.
|
||||
fn looks_again_at_installed_apps_every_few_minutes_not_every_frame() {
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let clock = Rc::new(Cell::new(NOW));
|
||||
let mut backend =
|
||||
Some(Fake { events: events(), sent: sent.clone(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: clock.clone() });
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
@@ -975,6 +1378,8 @@ fn looks_again_at_installed_apps_every_few_minutes_not_every_frame() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Update and restart queues `UpdateLauncher`, progress hides the button, and
|
||||
/// `LauncherRestarting` asks the host to quit; the update title is announced once.
|
||||
fn the_launcher_updates_itself_and_quits_to_restart() {
|
||||
struct QuitHost(Rc<Cell<bool>>);
|
||||
impl Host for QuitHost {
|
||||
@@ -1015,7 +1420,7 @@ fn the_launcher_updates_itself_and_quits_to_restart() {
|
||||
inner: Fake { events, sent: sent.clone(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) },
|
||||
later: shared_events.clone(),
|
||||
});
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
@@ -1030,7 +1435,12 @@ fn the_launcher_updates_itself_and_quits_to_restart() {
|
||||
None,
|
||||
);
|
||||
h.run_steps(6);
|
||||
h.get_by_label_contains("ArtCraft Launcher 0.2.0 is available");
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Label, "ArtCraft Launcher 0.2.0 is available.");
|
||||
assert_eq!(
|
||||
h.query_all_by_label_contains("ArtCraft Launcher 0.2.0 is available").count(),
|
||||
1,
|
||||
"the update title is announced once, not once by the icon and once by the heading"
|
||||
);
|
||||
h.get_by_label("Update and restart").click();
|
||||
h.run_steps(2);
|
||||
assert!(sent.borrow().contains(&Command::UpdateLauncher));
|
||||
@@ -1048,13 +1458,20 @@ fn the_launcher_updates_itself_and_quits_to_restart() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A found launcher update opens the relaunch prompt once; Later dismisses it and leaves the
|
||||
/// toolbar banner.
|
||||
fn launcher_update_prompts_to_relaunch_with_updated_version() {
|
||||
let mut ev = events();
|
||||
ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW)));
|
||||
let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0);
|
||||
h.run_steps(6);
|
||||
// Dialog pops up on launch with the update prompt
|
||||
h.get_by_label_contains("ArtCraft Launcher 0.2.0 is available");
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Label, "ArtCraft Launcher 0.2.0 is available.");
|
||||
assert_eq!(
|
||||
h.query_all_by_label_contains("ArtCraft Launcher 0.2.0 is available").count(),
|
||||
1,
|
||||
"the update title is announced once, not once by the icon and once by the heading"
|
||||
);
|
||||
h.get_by_label("A new version is available. Would you like to relaunch with the updated version?");
|
||||
h.get_by_label("What's new");
|
||||
// Clicking "Later" dismisses the modal dialog
|
||||
@@ -1078,6 +1495,8 @@ fn the_current_desktop_setting_is_only_offered_where_the_os_has_desktops() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A feed alert is acknowledged only by OK (persisted), while an inline announcement stays until
|
||||
/// its own Dismiss is clicked.
|
||||
fn feed_announcements_and_alerts_show_until_dismissed() {
|
||||
use artcraft_launcher_core::feed::Notice;
|
||||
let notice = |id: &str, title: &str, link: Option<&str>| Notice {
|
||||
@@ -1094,7 +1513,7 @@ fn feed_announcements_and_alerts_show_until_dismissed() {
|
||||
alerts: vec![notice("x1", "Security update", Some("https://getartcraft.com/security"))],
|
||||
});
|
||||
let mut backend = Some(Fake { events: evs, sent, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
@@ -1135,6 +1554,8 @@ fn automatic_checks_run_every_five_minutes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// With auto-update on, outdated apps are queued for Install and the launcher for
|
||||
/// `UpdateLauncher`.
|
||||
fn automatic_updates_queue_installs_for_outdated_apps_and_launcher() {
|
||||
let mut ev = events();
|
||||
ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW)));
|
||||
@@ -1142,7 +1563,7 @@ fn automatic_updates_queue_installs_for_outdated_apps_and_launcher() {
|
||||
let sent2 = sent.clone();
|
||||
let mut backend =
|
||||
Some(Fake { events: ev, sent: sent2, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
@@ -1164,6 +1585,7 @@ fn automatic_updates_queue_installs_for_outdated_apps_and_launcher() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// With auto-update off, no app install or launcher update is queued.
|
||||
fn automatic_updates_are_disabled_by_default() {
|
||||
let mut ev = events();
|
||||
ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW)));
|
||||
@@ -1171,7 +1593,7 @@ fn automatic_updates_are_disabled_by_default() {
|
||||
let sent2 = sent.clone();
|
||||
let mut backend =
|
||||
Some(Fake { events: ev, sent: sent2, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
@@ -1192,13 +1614,15 @@ fn automatic_updates_are_disabled_by_default() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The footer names the release origins actually in use, adding GitHub only when some release
|
||||
/// came from there.
|
||||
fn the_footer_names_the_services_behind_what_is_on_screen() {
|
||||
let footer = |extra: Vec<Event>| {
|
||||
let mut evs = events();
|
||||
evs.extend(extra);
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut backend = Some(Fake { events: evs, sent, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
@@ -1233,6 +1657,8 @@ fn web_build_offers_downloads() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A custom title bar shows Minimize, Maximize and Close, and Close requests the viewport close
|
||||
/// command.
|
||||
fn custom_title_bar_has_window_buttons() {
|
||||
struct FramelessHost;
|
||||
impl Host for FramelessHost {
|
||||
@@ -1248,7 +1674,7 @@ fn custom_title_bar_has_window_buttons() {
|
||||
asked: Vec::new(),
|
||||
clock: Rc::new(Cell::new(NOW)),
|
||||
});
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
@@ -1314,6 +1740,8 @@ fn maximized_the_close_button_reaches_the_screens_corner() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Clicking a card opens its details drawer with the page links, wrapping picture arrows and
|
||||
/// Esc-to-close.
|
||||
fn clicking_a_card_opens_its_details_drawer() {
|
||||
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(8);
|
||||
@@ -1322,6 +1750,17 @@ fn clicking_a_card_opens_its_details_drawer() {
|
||||
h.get_by_label("WordCraft details");
|
||||
h.get_by_label("PhotoCraft details").click();
|
||||
h.run_steps(10);
|
||||
h.check_accessibility();
|
||||
assert_eq!(
|
||||
h.query_all_by_role_and_label(egui::accesskit::Role::Image, "PhotoCraft").count(),
|
||||
0,
|
||||
"the hero icon is decorative; the wordmark carries the app name"
|
||||
);
|
||||
assert_eq!(
|
||||
h.query_all_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft").count(),
|
||||
2,
|
||||
"exactly the grid card's wordmark and the details drawer's wordmark carry the app name"
|
||||
);
|
||||
snapshot(&mut h, "details-dark");
|
||||
h.get_all_by_label_contains("The image editor you already know how to use").last().unwrap();
|
||||
h.get_by_label_contains("Curves and type layers · 1/4");
|
||||
@@ -1473,6 +1912,8 @@ fn a_page_without_pictures_has_no_gallery() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Enlarge adds a second, keyboard-navigable view over the drawer; Esc closes the enlarged
|
||||
/// picture before the drawer.
|
||||
fn the_lead_picture_can_be_enlarged() {
|
||||
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(8);
|
||||
@@ -1482,6 +1923,7 @@ fn the_lead_picture_can_be_enlarged() {
|
||||
assert_eq!(h.query_all_by_label("Close (Esc)").count(), 1);
|
||||
h.get_by_label("Enlarge picture").click();
|
||||
h.run_steps(4);
|
||||
h.check_accessibility();
|
||||
snapshot(&mut h, "details-zoom-dark");
|
||||
assert_eq!(h.query_all_by_label("Close (Esc)").count(), 2);
|
||||
// The keyboard flips through the pictures there too.
|
||||
@@ -1578,6 +2020,8 @@ fn the_drawer_and_the_enlarged_picture_survive_any_window_size() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// At 720–1180 px the rate-limit notice stays readable and clear of the footer's checked time
|
||||
/// and eight right-side links.
|
||||
fn the_rate_limit_notice_never_runs_under_the_footer_links() {
|
||||
for width in [720.0, 960.0, 1180.0] {
|
||||
let mut evs = events();
|
||||
@@ -1590,7 +2034,7 @@ fn the_rate_limit_notice_never_runs_under_the_footer_links() {
|
||||
asked: Vec::new(),
|
||||
clock: Rc::new(Cell::new(NOW)),
|
||||
});
|
||||
let mut h = Harness::builder().with_size(egui::vec2(width, 900.0)).build_ui_state(
|
||||
let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(width, 900.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
@@ -1626,6 +2070,8 @@ fn the_rate_limit_notice_never_runs_under_the_footer_links() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// At 720–1180 px refresh, search, sort and view share one unclipped toolbar row without
|
||||
/// overlaps.
|
||||
fn toolbar_keeps_navigation_refresh_search_and_view_on_one_unclipped_row() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
for width in [720.0, 800.0, 1180.0] {
|
||||
@@ -1634,10 +2080,18 @@ fn toolbar_keeps_navigation_refresh_search_and_view_on_one_unclipped_row() {
|
||||
let refresh = h.get_by_label("Check for updates").rect();
|
||||
let search = h.query_all_by_role(egui::accesskit::Role::TextInput).next().unwrap().rect();
|
||||
let view = h.get_by_label("Show apps as cards").rect();
|
||||
let sort = h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Sort").rect();
|
||||
assert!(refresh.top() > 48.0, "{width}: refresh belongs in the toolbar, not the header: {refresh:?}");
|
||||
assert!((refresh.center().y - search.center().y).abs() < 2.0, "{width}: refresh and search must share a row");
|
||||
assert!(refresh.right() <= search.left() + 1.0, "{width}: refresh overlaps search: {refresh:?}, {search:?}");
|
||||
assert!(refresh.right() <= sort.left() + 1.0, "{width}: sort overlaps refresh: {refresh:?}, {sort:?}");
|
||||
assert!(sort.right() <= search.left() + 1.0, "{width}: sort overlaps search: {sort:?}, {search:?}");
|
||||
assert!(search.right() <= view.left() + 1.0 && view.right() <= width, "{width}: search overlaps view or view is clipped");
|
||||
for (label, rect) in [("refresh", refresh), ("search", search), ("sort", sort), ("view", view)] {
|
||||
assert!(rect.left() >= 0.0 && rect.right() <= width, "{width}: {label} is outside the window: {rect:?}");
|
||||
}
|
||||
assert!(!sort.intersects(search) && !view.intersects(search), "{width}: toolbar controls overlap");
|
||||
h.check_accessibility();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1744,21 +2198,22 @@ fn short_window(reduce: Rc<Cell<bool>>) -> Harness<'static, Option<LauncherUi>>
|
||||
let mut backend =
|
||||
Some(Fake { events, sent: Rc::default(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut host = OsMotion(reduce);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 500.0)).with_step_dt(1.0 / 60.0).with_max_steps(120).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
{
|
||||
let settings = Settings { view: ViewMode::Grid, notify: false, ..Settings::default() };
|
||||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings));
|
||||
}
|
||||
if let Some(l) = state.as_mut() {
|
||||
l.logic(&ui.ctx().clone(), &mut host);
|
||||
l.ui(ui, &mut host);
|
||||
}
|
||||
},
|
||||
None,
|
||||
);
|
||||
let mut h =
|
||||
test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 500.0)).with_step_dt(1.0 / 60.0).with_max_steps(120).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
{
|
||||
let settings = Settings { view: ViewMode::Grid, notify: false, ..Settings::default() };
|
||||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings));
|
||||
}
|
||||
if let Some(l) = state.as_mut() {
|
||||
l.logic(&ui.ctx().clone(), &mut host);
|
||||
l.ui(ui, &mut host);
|
||||
}
|
||||
},
|
||||
None,
|
||||
);
|
||||
h.run_steps(8);
|
||||
h
|
||||
}
|
||||
@@ -1778,6 +2233,7 @@ fn turn_wheel(h: &mut Harness<'_, Option<LauncherUi>>, lines: f32) {
|
||||
delta: egui::vec2(0.0, -lines),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
source: egui::MouseWheelSource::Wheel,
|
||||
};
|
||||
let mut raw = egui::RawInput { events: vec![wheel], ..Default::default() };
|
||||
if let Some(l) = h.state() {
|
||||
@@ -1859,6 +2315,7 @@ fn with_animations_on_hovering_a_card_animates() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Command-plus-wheel over the main UI raises the global zoom factor.
|
||||
fn control_wheel_zooms_the_main_ui() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(4);
|
||||
@@ -1868,6 +2325,7 @@ fn control_wheel_zooms_the_main_ui() {
|
||||
phase: egui::TouchPhase::Move,
|
||||
delta: egui::vec2(0.0, 100.0),
|
||||
modifiers: egui::Modifiers::COMMAND,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
});
|
||||
h.step();
|
||||
h.run_steps(2);
|
||||
@@ -1875,13 +2333,27 @@ fn control_wheel_zooms_the_main_ui() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// With release notes open, keyboard and wheel zoom change only the notes text (reset works)
|
||||
/// while the global zoom stays put and the release heading appears once.
|
||||
fn changelog_shortcuts_zoom_only_the_notes_text() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(4);
|
||||
h.get_all_by_label("…").next().unwrap().click();
|
||||
// The grid's default order is name A-Z, so pick the app explicitly: the first card is not
|
||||
// PhotoCraft, and the heading assertion below is about this release's own page.
|
||||
h.get_by_label("More actions for PhotoCraft").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Release notes").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(
|
||||
h.query_all_by_role_and_label(egui::accesskit::Role::Image, "PhotoCraft 0.5.0").count(),
|
||||
0,
|
||||
"the Notes hero icon is decorative and must not repeat the release heading"
|
||||
);
|
||||
assert_eq!(
|
||||
h.query_all_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft 0.5.0").count(),
|
||||
1,
|
||||
"the release heading appears exactly once"
|
||||
);
|
||||
let before = h.get_by_label("What's Changed").rect().height();
|
||||
let global = h.ctx.zoom_factor();
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Plus);
|
||||
@@ -1897,6 +2369,7 @@ fn changelog_shortcuts_zoom_only_the_notes_text() {
|
||||
phase: egui::TouchPhase::Move,
|
||||
delta: egui::vec2(0.0, 100.0),
|
||||
modifiers: egui::Modifiers::COMMAND,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
});
|
||||
h.step();
|
||||
h.run_steps(2);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,7 +61,11 @@
|
||||
pname = "artcraft-launcher";
|
||||
version = version;
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
cargoLock = {
|
||||
lockFile = ./Cargo.lock;
|
||||
# Resolve Git dependencies at the immutable revisions in Cargo.lock.
|
||||
allowBuiltinFetchGit = true;
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
pkg-config
|
||||
|
||||
@@ -61,6 +61,7 @@ modules:
|
||||
build-commands:
|
||||
- cargo --offline build --release --locked -p artcraft-launcher
|
||||
- install -Dm755 target/release/artcraft-launcher /app/bin/artcraft-launcher
|
||||
- install -Dm644 NOTICE /app/share/doc/artcraft-launcher/NOTICE
|
||||
- install -Dm644 packaging/linux/ai.storyteller.artcraft-launcher.desktop /app/share/applications/ai.storyteller.artcraft-launcher.desktop
|
||||
- mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/
|
||||
- >-
|
||||
|
||||
@@ -135,7 +135,6 @@ if has appimage; then
|
||||
fi
|
||||
APPDIR="$WORK/ArtCraftLauncher.AppDir"
|
||||
cp -R "$STAGE" "$APPDIR"
|
||||
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
|
||||
# AppRun is a script, not a symlink: it installs the .desktop entry and icons into
|
||||
# $XDG_DATA_HOME before launching, so Wayland compositors can resolve the window's
|
||||
# app_id to our icon instead of the generic Wayland logo (opt-out env in the
|
||||
|
||||
@@ -118,6 +118,7 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
# Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile).
|
||||
cp "$WORK/bin/$PKG" "$APP/Contents/MacOS/$APP_NAME"
|
||||
cp "$ROOT/assets/app-icon/$PKG.icns" "$APP/Contents/Resources/$APP_NAME.icns"
|
||||
cp "$ROOT/NOTICE" "$APP/Contents/Resources/"
|
||||
sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
|
||||
-e "s/@BUILD_SHA@/${ARTCRAFT_LAUNCHER_BUILD_SHA:-unknown}/g" \
|
||||
"$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
<Fragment>
|
||||
<ComponentGroup Id="ArtCraftLauncherFiles">
|
||||
<Component Id="ArtCraftLauncherApp" Directory="INSTALLFOLDER" Guid="55DCC431-EBEF-4C11-BABB-6D78CE0ECF4E">
|
||||
<File Id="ArtCraftLauncherNotice" Source="$(var.BinDir)\NOTICE" KeyPath="no" />
|
||||
<File Id="ArtCraftLauncherExe" Source="$(var.BinDir)\artcraft-launcher.exe" KeyPath="no" />
|
||||
<!-- Read back by the next version's installer and by the launcher (self_update.rs). -->
|
||||
<RegistryValue Root="HKCU" Key="Software\Learning Machines LLC\ArtCraft Launcher" Name="InstallDir" Value="[INSTALLFOLDER]" Type="string" KeyPath="yes" />
|
||||
|
||||
@@ -90,6 +90,8 @@ Write-Output "ok ${Exe}: $Arch, PE subsystem $($h.Subsystem)"
|
||||
$Stage = Join-Path $TargetDir "windows-package\$Arch"
|
||||
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
|
||||
# Dependency font notices are required even without optional craft-fonts.
|
||||
Copy-Item -LiteralPath (Join-Path $Root 'NOTICE') -Destination $Stage
|
||||
Copy-Item (Join-Path $Bin $Exe) $Stage
|
||||
|
||||
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage $Exe)
|
||||
|
||||
+16
-4
@@ -597,14 +597,20 @@ const MAX_LINE_BYTES: usize = 8 * 1024;
|
||||
/// Maximum byte length for all request headers combined (CWE-400, F-019).
|
||||
const MAX_HEADER_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Sanitizes a string for safe terminal logging, escaping ASCII control characters
|
||||
/// and ANSI escape sequences to prevent terminal display manipulation (CWE-117, F-020, F-021).
|
||||
/// Sanitizes a string for safe terminal logging, escaping control characters and Unicode line
|
||||
/// separators to prevent terminal display manipulation (CWE-117, F-020, F-021).
|
||||
///
|
||||
/// `is_ascii_control` alone is not enough: C1 controls (U+0080..=U+009F), including the 8-bit CSI
|
||||
/// byte U+009B, can start or alter ANSI sequences in terminals, and U+2028/U+2029 act as line
|
||||
/// breaks in some log viewers. Those are escaped; ordinary printable Unicode stays as-is.
|
||||
fn sanitize_for_terminal(s: &str) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_control() || c == '\x1b' {
|
||||
use std::fmt::Write;
|
||||
if c.is_ascii_control() {
|
||||
let _ = write!(out, "\\x{:02x}", c as u32);
|
||||
} else if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') {
|
||||
let _ = write!(out, "\\u{{{:04x}}}", c as u32);
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
@@ -836,6 +842,12 @@ mod tests {
|
||||
assert_eq!(sanitize_for_terminal("\x1b[2Jevil"), "\\x1b[2Jevil");
|
||||
// CR / LF / Tab / Bell / Backspace
|
||||
assert_eq!(sanitize_for_terminal("foo\r\nbar\t\x07\x08"), "foo\\x0d\\x0abar\\x09\\x07\\x08");
|
||||
// C1 controls, including the 8-bit CSI (U+009B) and NEL (U+0085).
|
||||
assert_eq!(sanitize_for_terminal("a\u{9b}2Jb\u{85}c"), "a\\u{009b}2Jb\\u{0085}c");
|
||||
// Unicode line/paragraph separators can break a log line in some viewers.
|
||||
assert_eq!(sanitize_for_terminal("one\u{2028}two\u{2029}three"), "one\\u{2028}two\\u{2029}three");
|
||||
// Printable non-ASCII text is preserved.
|
||||
assert_eq!(sanitize_for_terminal("caf\u{e9} \u{1f642}"), "caf\u{e9} \u{1f642}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user