Compare commits
987 Commits
dependabot
...
main
Author | SHA1 | Date |
---|---|---|
JT | 3d9c193e02 | 6 months ago |
JT | ad79d6e13a | 6 months ago |
JT | 72917d0163 | 6 months ago |
JT | 72a3b9163b | 6 months ago |
JT | 9f1c3d5623 | 6 months ago |
JT | 4b86f28bb8 | 6 months ago |
JT | bf749904a5 | 6 months ago |
Ionite | b199f5e5fd | 6 months ago |
Ionite | 8a202c7aef | 6 months ago |
Ionite | 93a16cbd3e | 6 months ago |
JT | d7f977fb86 | 7 months ago |
let | 11fdbdfeed | 7 months ago |
JT | d544351dd2 | 7 months ago |
JT | 0ede21d512 | 7 months ago |
JT | 5c838e9f98 | 7 months ago |
JT | c7b638182f | 7 months ago |
JT | 7cea793c18 | 7 months ago |
JT | 9e530a00b6 | 7 months ago |
JT | 9b13cb5a76 | 7 months ago |
JT | 4524b9c15b | 7 months ago |
JT | aca2e7a767 | 7 months ago |
JT | 52597ad675 | 7 months ago |
JT | 9fd20a7dd6 | 7 months ago |
JT | e1a9202cf7 | 7 months ago |
JT | 5eaceaac37 | 7 months ago |
JT | ae82e598a6 | 7 months ago |
JT | 56f5dca3bb | 7 months ago |
JT | 7e436ba132 | 7 months ago |
JT | f06b9ef077 | 7 months ago |
JT | 919a013f1b | 7 months ago |
JT | 81cf470e2d | 7 months ago |
JT | 14b3bd191e | 7 months ago |
JT | 9ae41313aa | 7 months ago |
JT | 047d973b11 | 7 months ago |
jt | d543b1a36e | 7 months ago |
JT | e3df430df5 | 7 months ago |
JT | 1e1cb10940 | 7 months ago |
JT | c66c4972b8 | 7 months ago |
JT | 7b962dbae5 | 7 months ago |
JT | 2de9fdcdf6 | 7 months ago |
JT | 153f89ed87 | 7 months ago |
JT | db777eeb3c | 7 months ago |
JT | c18e93271e | 7 months ago |
JT | 61c229da44 | 7 months ago |
JT | 10d468f455 | 7 months ago |
JT | 72394aa1ae | 7 months ago |
JT | 805d3f61e6 | 7 months ago |
JT | 3b8b8cc7d5 | 7 months ago |
JT | e626c47305 | 7 months ago |
JT | d73ca680ab | 7 months ago |
JT | fa458e2866 | 7 months ago |
JT | 041ec59914 | 7 months ago |
JT | c285a0c110 | 7 months ago |
JT | a8b4d680a1 | 7 months ago |
JT | f86576c9d8 | 7 months ago |
JT | aa032e01b9 | 7 months ago |
JT | dbd9efb311 | 7 months ago |
JT | 805dde5bd5 | 7 months ago |
Ionite | 9843b6f7ae | 7 months ago |
JT | d441ea94ae | 7 months ago |
JT | f97cabc755 | 7 months ago |
JT | a66063b031 | 7 months ago |
JT | 921a54012e | 7 months ago |
JT | 42545f03f3 | 7 months ago |
JT | e28a3980bf | 7 months ago |
JT | f2dde2a50e | 7 months ago |
JT | 708743df47 | 7 months ago |
jt | 848468d525 | 7 months ago |
JT | 662022e191 | 7 months ago |
JT | d46b65f3d3 | 7 months ago |
JT | 68a9968794 | 7 months ago |
Ionite | a652287baf | 7 months ago |
Ionite | 64c55ec379 | 7 months ago |
Ionite | 128ef13229 | 7 months ago |
Ionite | 0273d8dd3c | 7 months ago |
Ionite | 68647f391e | 7 months ago |
Ionite | 01cc2f89a7 | 7 months ago |
Ionite | a75908e4ef | 7 months ago |
Ionite | d3b37229da | 7 months ago |
Ionite | 90e815a421 | 7 months ago |
JT | fa1c0a403f | 7 months ago |
JT | ef25d5b944 | 7 months ago |
Ionite | e41b4195dc | 7 months ago |
Ionite | 49aa554257 | 7 months ago |
JT | b95bf2cabd | 7 months ago |
JT | c029cc9660 | 7 months ago |
JT | 9ac2b91b21 | 7 months ago |
JT | 5d42c7f9d6 | 7 months ago |
JT | 56acf60ead | 7 months ago |
JT | 2f89eeba4d | 7 months ago |
Ionite | c2efe33c91 | 7 months ago |
Ionite | 2d961be0f3 | 7 months ago |
Ionite | b21e11c0c8 | 7 months ago |
Ionite | 224b008cd9 | 7 months ago |
Ionite | 38a104146f | 7 months ago |
Ionite | 94ff2fa40b | 7 months ago |
Ionite | 04e5459f75 | 7 months ago |
Ionite | 8e1c00312c | 7 months ago |
JT | 2a22a43cc8 | 7 months ago |
JT | 92ddc09d07 | 7 months ago |
JT | 795413780c | 7 months ago |
JT | 8ba15c73a1 | 7 months ago |
JT | 96f507619c | 7 months ago |
JT | 07af1c85d5 | 7 months ago |
JT | 7bf3a7b4ac | 7 months ago |
JT | d211db88ab | 7 months ago |
Ionite | 9716ae2bf2 | 7 months ago |
Ionite | 43f3c28b9a | 7 months ago |
Ionite | 4e42eee757 | 7 months ago |
JT | 60ab7bb3a7 | 7 months ago |
JT | 5441572e38 | 7 months ago |
JT | 612509f4ff | 7 months ago |
JT | c8ab3a743e | 8 months ago |
JT | 9b0e1b90f5 | 8 months ago |
JT | e5d215463e | 8 months ago |
JT | 3d1090573b | 8 months ago |
JT | 026b9d0cb1 | 8 months ago |
JT | 9c3e80cb4f | 8 months ago |
JT | 9b193f9cb5 | 8 months ago |
JT | 2148628aaa | 8 months ago |
JT | 773a3a0c1c | 8 months ago |
JT | 9937b1ec6e | 8 months ago |
Ionite | c6a8722ab2 | 8 months ago |
Ionite | df3a602e3a | 8 months ago |
Ionite | 2d95adbda7 | 8 months ago |
JT | 5d43a8df18 | 8 months ago |
Ionite | 6bb8cec4b8 | 8 months ago |
Ionite | 660dad4245 | 8 months ago |
JT | 77a60b1a59 | 8 months ago |
JT | 727b04ca01 | 8 months ago |
Ionite | 6621a8ce58 | 8 months ago |
Ionite | ff26180e8b | 8 months ago |
JT | f224c77349 | 8 months ago |
JT | 67ce55d429 | 8 months ago |
Ionite | ae2c9b2783 | 8 months ago |
Ionite | 395d9c3963 | 8 months ago |
Ionite | 3161a009c7 | 8 months ago |
Ionite | d6920be164 | 8 months ago |
Ionite | 255b1feb66 | 8 months ago |
Ionite | 52b32f9227 | 8 months ago |
Ionite | 51b6e1be36 | 8 months ago |
Ionite | cefb08e4da | 8 months ago |
Ionite | 92d42b3c53 | 8 months ago |
Ionite | af89e07c01 | 8 months ago |
Ionite | d37a2df700 | 8 months ago |
Ionite | 4058742cd9 | 8 months ago |
JT | 5d6638334a | 8 months ago |
JT | 9d99c59747 | 8 months ago |
JT | 61abe7a1f6 | 8 months ago |
JT | 04db64fcfb | 8 months ago |
JT | 5419e7a53b | 8 months ago |
JT | ca67f73ff6 | 8 months ago |
JT | c7c5653ff3 | 8 months ago |
JT | 26c9db8b14 | 8 months ago |
JT | 772d93d46d | 8 months ago |
JT | 870aa20a80 | 8 months ago |
JT | 72f4cf04a7 | 8 months ago |
JT | 0409e0354d | 8 months ago |
JT | 864391db97 | 8 months ago |
JT | 597c4c9aec | 8 months ago |
JT | 035d54c844 | 8 months ago |
JT | 73771a29c1 | 8 months ago |
JT | 3af08e04f3 | 8 months ago |
JT | b2a76056af | 8 months ago |
JT | f40f0b6cc0 | 8 months ago |
JT | 7b01968b00 | 8 months ago |
JT | f30181caa8 | 8 months ago |
JT | c217189ade | 8 months ago |
JT | 8d59e1d9f2 | 8 months ago |
JT | d2ceefdd19 | 8 months ago |
JT | 4fe73777e6 | 8 months ago |
JT | d36f7c8c4a | 8 months ago |
Ionite | 565cfa918b | 8 months ago |
JT | c8a2afec28 | 8 months ago |
JT | 2b5db18871 | 8 months ago |
JT | acca9e685b | 8 months ago |
JT | 29b6863943 | 8 months ago |
JT | b913e45d5c | 8 months ago |
JT | 9ca69298d5 | 8 months ago |
JT | d31ae40b2d | 8 months ago |
JT | fcca600fba | 8 months ago |
JT | 233eba2fce | 8 months ago |
JT | 4393c01899 | 8 months ago |
JT | cbc37fb466 | 8 months ago |
JT | 0eb88e2255 | 8 months ago |
JT | 4846d69043 | 8 months ago |
JT | 19cd60c195 | 8 months ago |
JT | 078a471eaa | 8 months ago |
JT | db239e7c2e | 8 months ago |
JT | 6657cf59ea | 8 months ago |
JT | 8810e18ef6 | 8 months ago |
JT | 76cf8fbd41 | 8 months ago |
JT | 50b142ba54 | 8 months ago |
JT | 38013c16d6 | 8 months ago |
JT | d01cc2aeb4 | 8 months ago |
JT | ec4c7343da | 8 months ago |
JT | 86bcdaf86f | 8 months ago |
JT | a5beaf434c | 8 months ago |
Ionite | 1779da0210 | 8 months ago |
Ionite | 479aceebb0 | 8 months ago |
Ionite | b74ee76130 | 8 months ago |
JT | 662c575fe8 | 8 months ago |
Ionite | c119a3be91 | 8 months ago |
JT | 87828cb172 | 8 months ago |
JT | 9c78ef2197 | 8 months ago |
Ionite | 8503e937ed | 8 months ago |
Ionite | c028a51d71 | 8 months ago |
JT | 243115b459 | 8 months ago |
JT | 2f0e081e90 | 8 months ago |
JT | 977219995d | 8 months ago |
JT | 3c23d6774e | 8 months ago |
JT | b28aef1e7b | 8 months ago |
JT | 8cebc775e8 | 8 months ago |
JT | a58f4a9cc0 | 8 months ago |
JT | 393943abad | 8 months ago |
JT | 79698b8b1f | 8 months ago |
JT | d7086051d8 | 8 months ago |
JT | f2051dfeb1 | 8 months ago |
JT | e4aa0f85a5 | 8 months ago |
JT | 4d42cab3f4 | 8 months ago |
JT | 94cb39ce2b | 8 months ago |
JT | dada768a75 | 8 months ago |
JT | 67dd8c53b6 | 8 months ago |
JT | e0f04bc87e | 8 months ago |
JT | 8fdd737c51 | 8 months ago |
JT | 88504e0304 | 8 months ago |
JT | 99c8c926f2 | 8 months ago |
Ionite | b418a38ba5 | 8 months ago |
Ionite | bd28f00d8b | 8 months ago |
Ionite | 807a20c25b | 8 months ago |
Ionite | 8c2ea813db | 8 months ago |
Ionite | ec46a9c275 | 8 months ago |
Ionite | 23a4ec9fd2 | 8 months ago |
JT | c1d697bfcd | 8 months ago |
JT | 0e6cf4db7e | 8 months ago |
Ionite | 4be0ea4c8c | 8 months ago |
JT | 17560f40fe | 8 months ago |
JT | 18190dced1 | 8 months ago |
JT | f61b2255d2 | 8 months ago |
JT | b8ad0a7b01 | 8 months ago |
JT | a09775a207 | 8 months ago |
JT | 49a88ff774 | 8 months ago |
JT | bbb35f7eaf | 8 months ago |
JT | bc04756169 | 8 months ago |
JT | 56b6a2c279 | 8 months ago |
JT | 02c64e2df1 | 8 months ago |
JT | 7c2f51cc09 | 8 months ago |
Ionite | 1270c13ede | 8 months ago |
Ionite | 80f9bf3a22 | 8 months ago |
Ionite | 2695f94b8b | 8 months ago |
Ionite | f378b8dac8 | 8 months ago |
Ionite | f781fbb229 | 8 months ago |
Ionite | 1dfd2eb4e5 | 8 months ago |
Ionite | d54e28acb4 | 8 months ago |
Ionite | 810b22cd08 | 8 months ago |
Ionite | 3a3f94621b | 8 months ago |
Ionite | 0738c491af | 8 months ago |
Ionite | adaff0357e | 8 months ago |
Ionite | 425b343480 | 8 months ago |
Ionite | 4bfa567ed4 | 8 months ago |
Ionite | 30609dc486 | 8 months ago |
Ionite | 664c8e5ba5 | 8 months ago |
Ionite | e7be967cdd | 8 months ago |
Ionite | 8b0bd6acbc | 8 months ago |
JT | 005826f276 | 8 months ago |
JT | 645d91055a | 8 months ago |
JT | 86d33c952f | 8 months ago |
JT | a6cb9751ac | 8 months ago |
JT | a0ded63b5e | 8 months ago |
JT | f8f3cd09b4 | 8 months ago |
JT | 6cc3596c56 | 8 months ago |
JT | 66fa815d6b | 8 months ago |
JT | c6fc37efaa | 8 months ago |
Ionite | cccedd3b9b | 8 months ago |
Ionite | 030a25594c | 8 months ago |
Ionite | eee89bba1a | 8 months ago |
JT | c6ed1ba879 | 8 months ago |
JT | c885520193 | 8 months ago |
JT | 8edafa642c | 8 months ago |
JT | 78ec821ff9 | 8 months ago |
JT | d8ad305391 | 8 months ago |
JT | e93a097f71 | 8 months ago |
Ionite | 93b820c7e4 | 8 months ago |
Ionite | 1fa934a5fa | 8 months ago |
Ionite | b805a9e91c | 8 months ago |
Ionite | f9e305b803 | 8 months ago |
Ionite | 6055b7454d | 8 months ago |
Ionite | 75be254457 | 9 months ago |
Ionite | 9de2bfe668 | 9 months ago |
JT | 6fb5b0cb17 | 9 months ago |
JT | c2b3337fe3 | 9 months ago |
JT | 9facb59287 | 9 months ago |
JT | 8bbeae6b5e | 9 months ago |
JT | aed6e79957 | 9 months ago |
JT | b394e5b701 | 9 months ago |
JT | 987a224681 | 9 months ago |
JT | 09c72677b7 | 9 months ago |
Ionite | d5f726d9d6 | 9 months ago |
JT | 8c2e38e8ec | 9 months ago |
JT | 7a4a403bdb | 9 months ago |
JT | ea9dc5a3d2 | 9 months ago |
JT | 2a574c2cd8 | 9 months ago |
JT | 9abbaa0f16 | 9 months ago |
Ionite | a15dd34d97 | 9 months ago |
JT | 29dd89550d | 9 months ago |
Ionite | 5a53afa766 | 9 months ago |
Ionite | 80585f8640 | 9 months ago |
Ionite | ac2b9a86d6 | 9 months ago |
Ionite | 6f1f2662c7 | 9 months ago |
Ionite | 298e577a72 | 9 months ago |
Ionite | d11dc9e69d | 9 months ago |
Ionite | e94760b5ce | 9 months ago |
Ionite | c2e62926f3 | 9 months ago |
Ionite | 5b378a381e | 9 months ago |
Ionite | b73f0de1d9 | 9 months ago |
Ionite | 536818e7e9 | 9 months ago |
Ionite | 9f3a891bdc | 9 months ago |
Ionite | a8af39780a | 9 months ago |
Ionite | 4fdf4159d9 | 9 months ago |
JT | a47ee7eb96 | 9 months ago |
JT | 54102b2ea2 | 9 months ago |
JT | 685662302d | 9 months ago |
JT | 494f5f9e32 | 9 months ago |
JT | 8f53c281b3 | 9 months ago |
Ionite | 842ab35e7d | 9 months ago |
JT | 11ddd89b09 | 9 months ago |
JT | 961cf365e9 | 9 months ago |
Ionite | 08e1034abf | 9 months ago |
Ionite | 2862e90622 | 9 months ago |
Ionite | 37f1ac512c | 9 months ago |
Ionite | 96c30f5d40 | 9 months ago |
Ionite | d329f37e9d | 9 months ago |
JT | 24b34f76d6 | 9 months ago |
JT | 2303759eec | 9 months ago |
JT | cd15b76505 | 9 months ago |
JT | 54b1e9a216 | 9 months ago |
Ionite | ecc3b7da2d | 9 months ago |
Ionite | 3653fb475e | 9 months ago |
Ionite | deb86fef83 | 9 months ago |
Ionite | cd0957825f | 9 months ago |
Ionite | 4cccde8a4b | 9 months ago |
Ionite | a0221c7d91 | 9 months ago |
Ionite | e1816f71c6 | 9 months ago |
JT | a17e4f202a | 9 months ago |
JT | ff84b469ca | 9 months ago |
JT | f27671bc9d | 9 months ago |
JT | 51c378b363 | 9 months ago |
JT | 477d5302dd | 9 months ago |
JT | 7e63065df5 | 9 months ago |
JT | bce305b216 | 9 months ago |
Ionite | dc4e99b489 | 9 months ago |
JT | 60a5ae4da7 | 9 months ago |
JT | fb3257ed4f | 9 months ago |
JT | 50b7c2cdff | 9 months ago |
JT | 53762b678f | 9 months ago |
JT | 736ea79292 | 9 months ago |
JT | 9d78a45cd8 | 9 months ago |
JT | 7e7b0148aa | 9 months ago |
JT | 91fb39f366 | 9 months ago |
JT | 45f48ce36f | 9 months ago |
Ionite | 1ea83f30f9 | 9 months ago |
Ionite | e62546691a | 9 months ago |
Ionite | 0c30b4ba32 | 9 months ago |
Ionite | df1f188fe6 | 9 months ago |
Ionite | 92620dc894 | 9 months ago |
Ionite | eafb905929 | 9 months ago |
JT | f7c99cdba9 | 9 months ago |
JT | eb78ec5010 | 9 months ago |
Ionite | 27837e82a4 | 9 months ago |
Ionite | 59fecaeb2e | 9 months ago |
Ionite | 75cdf9b673 | 9 months ago |
Ionite | 64a00cb1c4 | 9 months ago |
Ionite | 34916633b6 | 9 months ago |
Ionite | d8587466d0 | 9 months ago |
JT | fb8f06d611 | 9 months ago |
Ionite | fe1cf2f423 | 9 months ago |
Ionite | 32fa8d6607 | 9 months ago |
Ionite | 97c1d15e20 | 9 months ago |
Ionite | 56455ace1b | 9 months ago |
Ionite | 7c746138e3 | 9 months ago |
JT | d4354d0d20 | 9 months ago |
JT | 49273b049a | 9 months ago |
JT | 6a876045d7 | 9 months ago |
JT | 6a66f3e1b7 | 9 months ago |
JT | c9a9c4c805 | 9 months ago |
JT | bb7ad63817 | 9 months ago |
JT | bc98c56dcd | 9 months ago |
JT | 2c38289386 | 9 months ago |
JT | af76365153 | 9 months ago |
Ionite | 872c3b0c0e | 9 months ago |
Ionite | 4e3e07205c | 9 months ago |
Ionite | 2eb631d504 | 9 months ago |
Ionite | b0cec3d3ea | 9 months ago |
JT | a1735e5837 | 9 months ago |
JT | fbc8111952 | 9 months ago |
JT | b5529f6ece | 9 months ago |
Ionite | c1aca685f8 | 9 months ago |
Ionite | 74605f6513 | 9 months ago |
Ionite | c968846d87 | 9 months ago |
Ionite | 0e65abe103 | 9 months ago |
Ionite | 49398c3386 | 9 months ago |
Ionite | 80f28815d3 | 9 months ago |
Ionite | c34f608912 | 9 months ago |
Ionite | 20e28d0443 | 9 months ago |
Ionite | 5025822d02 | 9 months ago |
Ionite | d12e04a69b | 9 months ago |
Ionite | 833950c349 | 9 months ago |
Ionite | d7aa73a260 | 9 months ago |
Ionite | 942013673a | 9 months ago |
Ionite | a6f09bc4b4 | 9 months ago |
Ionite | e241a15873 | 9 months ago |
Ionite | ffb6da8d18 | 9 months ago |
Ionite | c215b0c73a | 9 months ago |
Ionite | 2d22db1374 | 9 months ago |
Ionite | d0c650051a | 9 months ago |
Ionite | a119dde5b0 | 9 months ago |
Ionite | 2a8088fd4a | 9 months ago |
Ionite | ebf6ad47f6 | 9 months ago |
Ionite | 27dd1576cb | 9 months ago |
Ionite | 4c3897efa4 | 9 months ago |
Ionite | 44a289565d | 9 months ago |
jt | c7d6f66909 | 9 months ago |
JT | 7e3e520b75 | 9 months ago |
JT | be8d2cd78d | 9 months ago |
JT | 4ce285a849 | 9 months ago |
jt | bb465b502d | 9 months ago |
Ionite | 31f059f12e | 9 months ago |
Ionite | fceb661063 | 9 months ago |
Ionite | 19383fde5a | 9 months ago |
JT | 5e7b9286ac | 9 months ago |
JT | d4abd58d75 | 9 months ago |
JT | 254f79e5bb | 9 months ago |
JT | 57a0597296 | 9 months ago |
Ionite | 39a7023946 | 9 months ago |
Ionite | 9a05a787b9 | 9 months ago |
Ionite | c3022320bf | 9 months ago |
Ionite | e8b0c0902f | 9 months ago |
Ionite | fe11c8d50b | 9 months ago |
Ionite | 0d81845b95 | 9 months ago |
Ionite | a2bff10a1b | 9 months ago |
Ionite | 7e80d9847d | 9 months ago |
JT | 1e6049581e | 9 months ago |
JT | 439f59f311 | 9 months ago |
jt | 244bca04b0 | 9 months ago |
Ionite | 69a2c08b5d | 9 months ago |
Ionite | c495f657e6 | 9 months ago |
Ionite | d5bdfc5355 | 9 months ago |
Ionite | efd5614799 | 9 months ago |
Ionite | df4d69a9ed | 9 months ago |
Ionite | 24a448d2a3 | 9 months ago |
Ionite | 6592198b4d | 9 months ago |
Ionite | 32d46c5cf5 | 9 months ago |
Ionite | 6919e6841a | 9 months ago |
Ionite | 2e05f62921 | 9 months ago |
Ionite | ac7c599e07 | 9 months ago |
Ionite | ce28b9a2d2 | 9 months ago |
JT | 889877724b | 9 months ago |
Ionite | eb918f1f54 | 9 months ago |
Ionite | 4bbc0c8275 | 9 months ago |
Ionite | a3d95abf2d | 9 months ago |
Ionite | b5e5ccf38c | 9 months ago |
Ionite | b7485490d3 | 9 months ago |
Ionite | 00e0bfc3c3 | 9 months ago |
Ionite | 188be7bb45 | 9 months ago |
Ionite | 07e3d05f27 | 9 months ago |
Ionite | 993eb15e8d | 9 months ago |
JT | 9f430ecb65 | 9 months ago |
Ionite | 95c8e9e8d1 | 9 months ago |
Ionite | cb00e6a4c4 | 9 months ago |
Ionite | f8e3d80bbc | 9 months ago |
Ionite | 48fc6d5139 | 9 months ago |
JT | e87b5032be | 9 months ago |
Ionite | a3829d6a68 | 9 months ago |
JT | c26960ce1f | 9 months ago |
Ionite | a62038d19c | 9 months ago |
Ionite | dace196657 | 9 months ago |
Ionite | b91a24e451 | 9 months ago |
Ionite | f2ac6abb30 | 9 months ago |
Ionite | 113df1968e | 9 months ago |
JT | b4af5906fd | 9 months ago |
JT | b88784f069 | 9 months ago |
JT | 5a41985c04 | 9 months ago |
JT | 2998ceee21 | 9 months ago |
Ionite | a18014db76 | 9 months ago |
Ionite | 19f4663763 | 9 months ago |
JT | 6c7c6ead56 | 9 months ago |
Ionite | 201ff487d3 | 9 months ago |
Ionite | 0aa5a49cce | 9 months ago |
Ionite | 55dc469425 | 9 months ago |
Ionite | f822f4d79f | 9 months ago |
Ionite | 71a6c463a8 | 9 months ago |
Ionite | 939ca5308d | 9 months ago |
Ionite | 9c00cb6373 | 9 months ago |
JT | 89f0545590 | 9 months ago |
Ionite | a0ff4a07dc | 9 months ago |
JT | c477e065e6 | 9 months ago |
JT | f2f8e678cd | 9 months ago |
Ionite | a4234836c5 | 9 months ago |
JT | a4040d16a1 | 9 months ago |
JT | 269a9127ed | 9 months ago |
JT | 59af8bc07d | 9 months ago |
JT | 4f3c4718f7 | 9 months ago |
JT | a0d6ae12f4 | 9 months ago |
Ionite | dfc27fc05d | 9 months ago |
JT | 587fee5b77 | 9 months ago |
Ionite | 735a2aa225 | 9 months ago |
JT | 5915b131ed | 9 months ago |
JT | 842950b6e3 | 9 months ago |
JT | 112cc652de | 9 months ago |
Ionite | 2691fd2462 | 9 months ago |
Ionite | 43869300ee | 9 months ago |
Ionite | 120526d950 | 9 months ago |
Ionite | cd178b6f5d | 9 months ago |
Ionite | ed61fa66b8 | 9 months ago |
Ionite | 3b7653488a | 9 months ago |
ionite34 | c6ae44cc8e | 9 months ago |
ionite34 | a26de6b9f2 | 9 months ago |
ionite34 | 027d846216 | 9 months ago |
Ionite | 14c47a7175 | 9 months ago |
Ionite | 604fe366b3 | 9 months ago |
Ionite | 8c97a4f792 | 9 months ago |
ionite34 | b9a5cb3786 | 9 months ago |
ionite34 | d7a03edc32 | 9 months ago |
ionite34 | 1b4cadf5da | 9 months ago |
JT | 6007de8b7c | 9 months ago |
Ionite | c6a4af1fc1 | 9 months ago |
JT | d9a7ec3489 | 9 months ago |
JT | e0b2d6b308 | 9 months ago |
Ionite | edb81c106b | 9 months ago |
JT | 114cc12d24 | 9 months ago |
JT | 9a72a910cc | 9 months ago |
Ionite | 9bd67b1e39 | 9 months ago |
Ionite | ab4fc7633a | 9 months ago |
Ionite | 5c0c582d0c | 9 months ago |
Ionite | 14f1ffd31f | 9 months ago |
Ionite | 909a2903fb | 10 months ago |
JT | ea27537ff9 | 10 months ago |
JT | d309c24a02 | 10 months ago |
Ionite | fb122d9c7d | 10 months ago |
ionite34 | 2e4989c521 | 10 months ago |
ionite34 | 3ea66c2bdf | 10 months ago |
ionite34 | ad19b620c8 | 10 months ago |
Ionite | 7995b90327 | 10 months ago |
ionite34 | 0c805f3c0d | 10 months ago |
Ionite | a67131f0b9 | 10 months ago |
Ionite | 0d506bf45a | 10 months ago |
ionite34 | 4cc5e43e33 | 10 months ago |
JT | b7aa787b3f | 10 months ago |
JT | 3ca62b172a | 10 months ago |
ionite34 | 23c0dea443 | 10 months ago |
ionite34 | 468b5743a4 | 10 months ago |
ionite34 | 3a0f5b884d | 10 months ago |
ionite34 | a91bbb9319 | 10 months ago |
JT | 2b51c5f49e | 10 months ago |
JT | 90cb25ccbb | 10 months ago |
JT | e6c329aafe | 10 months ago |
JT | 19ec7e0612 | 10 months ago |
Ionite | 8c8a167bb0 | 10 months ago |
Ionite | 60d5357572 | 10 months ago |
Ionite | e56b9d0c74 | 10 months ago |
JT | 5b816adbe8 | 10 months ago |
JT | d712eefc5a | 10 months ago |
JT | 79afd1454c | 10 months ago |
JT | c663482177 | 10 months ago |
JT | 598ee6ef45 | 10 months ago |
JT | c69f38dacb | 10 months ago |
Ionite | be5ca65dcd | 10 months ago |
slogonomo | a11eaf2afb | 10 months ago |
JT | f0dfa05d27 | 10 months ago |
JT | bb5ff4a8d7 | 10 months ago |
JT | ae6d90b76b | 10 months ago |
JT | 8cbe030338 | 10 months ago |
Ionite | 86286b9127 | 10 months ago |
Ionite | 5a6a20dab7 | 10 months ago |
Ionite | 96f55c537c | 10 months ago |
JT | 16d9bf5bc6 | 10 months ago |
JT | 7194657b35 | 10 months ago |
JT | 086d79835e | 10 months ago |
JT | 0c6a74b96e | 10 months ago |
JT | d14bd651b5 | 10 months ago |
JT | 9330cc98ed | 10 months ago |
JT | 2a132d4f16 | 10 months ago |
Ionite | 2c2b3875b0 | 10 months ago |
Ionite | f06128d299 | 10 months ago |
Ionite | 31981760e0 | 10 months ago |
Ionite | a6e5bd623c | 10 months ago |
Ionite | 0b99879db6 | 10 months ago |
Ionite | 256f6ef394 | 10 months ago |
Ionite | 0f5ffda5ce | 10 months ago |
Ionite | 7c752890b3 | 10 months ago |
Ionite | 5db1e6e428 | 10 months ago |
Ionite | daf6071afc | 10 months ago |
Ionite | e19f18d0d4 | 10 months ago |
JT | 2c495730c6 | 10 months ago |
JT | dd87c66c00 | 10 months ago |
JT | e736708e74 | 10 months ago |
Ionite | 3d5a4aeea3 | 10 months ago |
Ionite | 76cff0b5cd | 10 months ago |
Ionite | 1decfaeae4 | 10 months ago |
Ionite | c2fb73a5e5 | 10 months ago |
Ionite | 633a61863e | 10 months ago |
Ionite | 5c2a3087db | 10 months ago |
Ionite | dc16dfb76b | 10 months ago |
Ionite | 5c4101d364 | 10 months ago |
Ionite | afeef2d97f | 10 months ago |
Ionite | ccdf6c9d32 | 10 months ago |
JT | 810022ccf3 | 10 months ago |
JT | d3f3c858a0 | 10 months ago |
JT | c39f560ed8 | 10 months ago |
Ionite | 145a8da9f1 | 10 months ago |
Ionite | edd4460981 | 10 months ago |
JT | 7f0325f086 | 10 months ago |
Ionite | 7f1664c519 | 10 months ago |
Ionite | d01651e833 | 10 months ago |
Ionite | 783934a447 | 10 months ago |
Ionite | 2d2a54c644 | 10 months ago |
Ionite | 39ed0084e9 | 10 months ago |
JT | 13ae4d7330 | 10 months ago |
JT | 01c7847484 | 10 months ago |
JT | 585b4771da | 10 months ago |
Ionite | dd1f3d2a14 | 10 months ago |
Ionite | 3645ff4102 | 10 months ago |
Ionite | 6d8fb3258f | 10 months ago |
Ionite | 97e7f6fb9a | 10 months ago |
Ionite | 91a9bb8eee | 10 months ago |
Ionite | ba195d259a | 10 months ago |
Ionite | 6a151a5c2f | 10 months ago |
Ionite | aeb63527f7 | 10 months ago |
Ionite | 653782ef5a | 10 months ago |
Ionite | 2d12d1cbe8 | 10 months ago |
JT | b7f0f0d680 | 10 months ago |
JT | 33edda5778 | 10 months ago |
JT | 12f904267a | 10 months ago |
JT | 172b2a1a8c | 10 months ago |
JT | 2088d3c0aa | 10 months ago |
JT | a45949fdbb | 10 months ago |
JT | 3003e05e7d | 10 months ago |
JT | 47b15f4808 | 10 months ago |
JT | 72c8f264d7 | 10 months ago |
JT | da7d3830cc | 10 months ago |
jt | 621073c7c3 | 10 months ago |
JT | 94e0c2d7ba | 10 months ago |
JT | e87a63f9d1 | 10 months ago |
JT | 929d5e0298 | 10 months ago |
JT | b656d4c3d1 | 10 months ago |
JT | c85bf77fcb | 10 months ago |
Ionite | a1bab3a03c | 10 months ago |
JT | 3ad56e66ee | 10 months ago |
JT | 476756aab0 | 10 months ago |
JT | 68aec6a218 | 10 months ago |
JT | 83994c9384 | 10 months ago |
JT | d157418327 | 10 months ago |
JT | 1a8fd000f5 | 10 months ago |
JT | 4831e1fe58 | 10 months ago |
JT | d18b3a30fb | 10 months ago |
JT | e2133c50ba | 10 months ago |
JT | 0a513ff25c | 10 months ago |
JT | 490745f77c | 10 months ago |
JT | c39e9b6c75 | 10 months ago |
Ionite | 78b150c1c2 | 10 months ago |
Ionite | 1ce19c8493 | 10 months ago |
Ionite | cf7636bb19 | 10 months ago |
Ionite | 05a4c999bd | 10 months ago |
Ionite | df22b23916 | 10 months ago |
Ionite | 9954647020 | 10 months ago |
Ionite | f6a2fecce0 | 10 months ago |
Ionite | e21c1bb0c4 | 10 months ago |
Ionite | cc3fa54dac | 10 months ago |
Ionite | 893951c46a | 10 months ago |
Ionite | 40028cec6a | 10 months ago |
Ionite | 3e02f223b9 | 10 months ago |
Ionite | f380a7676e | 10 months ago |
Ionite | e68b91e3d6 | 10 months ago |
Ionite | 59e0df2773 | 10 months ago |
Ionite | 6828245e94 | 10 months ago |
Ionite | 928c283625 | 10 months ago |
Ionite | 0a540a8f39 | 10 months ago |
Ionite | 94e36ed683 | 10 months ago |
Ionite | 8a4eaae59f | 10 months ago |
Ionite | a35c1f37a9 | 10 months ago |
Ionite | 23a66f4ceb | 10 months ago |
Ionite | 625e7b279c | 10 months ago |
JT | abe631df13 | 10 months ago |
JT | e2b355e443 | 10 months ago |
JT | 862951a4d6 | 10 months ago |
JT | 2b037f44c7 | 10 months ago |
Ionite | 6b0d5b726b | 10 months ago |
Ionite | d864004d26 | 10 months ago |
Ionite | fb15ba7110 | 10 months ago |
Ionite | 51b6d0c9b3 | 10 months ago |
JT | d7ed54edf1 | 10 months ago |
JT | caa3a6638b | 10 months ago |
JT | 15a09d2f81 | 10 months ago |
Ionite | d0ecac15b7 | 10 months ago |
Ionite | 6a78e5d752 | 10 months ago |
Ionite | 47736c828c | 10 months ago |
Ionite | 3827c189c4 | 10 months ago |
JT | 362d2b9e9a | 10 months ago |
JT | f2997610d6 | 10 months ago |
Ionite | 436003d183 | 10 months ago |
Ionite | 0b3ec589e5 | 10 months ago |
Ionite | 3ca0f3670d | 10 months ago |
Ionite | 28fdd6c235 | 10 months ago |
Ionite | 7d437a55e5 | 10 months ago |
Ionite | bd8d4885dd | 10 months ago |
Ionite | 0c11f30f05 | 10 months ago |
JT | 530a79bc1d | 10 months ago |
JT | 12d4b126c8 | 10 months ago |
JT | ed707d612c | 10 months ago |
JT | b78dbc961f | 10 months ago |
JT | e986af1803 | 10 months ago |
JT | 5e993c721a | 10 months ago |
JT | c80c01f3c5 | 10 months ago |
Ionite | 457c326c6e | 10 months ago |
Ionite | 116f5dc905 | 10 months ago |
Ionite | 3f10a705d4 | 10 months ago |
Ionite | e5b316ca48 | 10 months ago |
Ionite | 843b83d0e9 | 10 months ago |
Ionite | 03e9e5417c | 10 months ago |
Ionite | c548ea43e3 | 10 months ago |
Ionite | f41f5b1fde | 10 months ago |
JT | dd1e1cec3c | 10 months ago |
jt | a40d820d6a | 10 months ago |
JT | 5f443ace4b | 10 months ago |
JT | 57a087f95e | 10 months ago |
JT | afa92b7b57 | 10 months ago |
JT | f663b101ba | 10 months ago |
JT | 7d2675d18e | 10 months ago |
ionite34 | 30d840afb4 | 10 months ago |
JT | bc1b912c7a | 10 months ago |
JT | 713e32473a | 10 months ago |
JT | 7dbc5d53ae | 10 months ago |
JT | 1a4aa0dc15 | 10 months ago |
JT | 19938536e4 | 10 months ago |
JT | 027f4175f4 | 10 months ago |
JT | 6eca39bc5e | 10 months ago |
JT | da304547f4 | 10 months ago |
jt | 4e21fd9c3a | 10 months ago |
JT | c242bf1435 | 10 months ago |
Ionite | eda036ce69 | 10 months ago |
ionite34 | 226dcc9cf2 | 10 months ago |
JT | 3ab6a2cf41 | 10 months ago |
JT | b70cd8a549 | 10 months ago |
JT | c10c04a5c5 | 10 months ago |
JT | 95c2e43470 | 10 months ago |
ionite34 | 5f874183d1 | 10 months ago |
JT | 7779739ed6 | 10 months ago |
JT | 0325fe01a6 | 10 months ago |
JT | 4e0026f7d2 | 10 months ago |
JT | 2f55c80996 | 10 months ago |
JT | b587bac08d | 10 months ago |
JT | 6f144714a8 | 10 months ago |
JT | fbe3eb176b | 10 months ago |
JT | cb94fabf84 | 10 months ago |
JT | aa97ac9fcb | 10 months ago |
Ionite | 8945b409d6 | 10 months ago |
JT | cfe2be8453 | 10 months ago |
JT | ca6974894e | 10 months ago |
Ionite | 0b88add34d | 10 months ago |
Ionite | 0210d59fd4 | 10 months ago |
Ionite | 6a34795c8e | 10 months ago |
ionite34 | 34908a2f00 | 10 months ago |
Ionite | 0feb679d94 | 10 months ago |
ionite34 | 7e8cb3298a | 10 months ago |
ionite34 | 1eff8c0969 | 10 months ago |
ionite34 | 8be10bd9d1 | 10 months ago |
ionite34 | 9ed0bf67ce | 10 months ago |
JT | 6dc34207f6 | 10 months ago |
JT | 8f1d28bd3f | 10 months ago |
ionite34 | 3da476fa2e | 10 months ago |
ionite34 | 76660fdb03 | 10 months ago |
Ionite | e04f3ef75c | 10 months ago |
ionite34 | 9da30935f1 | 10 months ago |
ionite34 | cec7342f6d | 10 months ago |
JT | e071006475 | 10 months ago |
JT | 7c0a100b54 | 10 months ago |
psi | a9519cdbc6 | 10 months ago |
Ionite | 7bcd1ec5fa | 10 months ago |
ionite34 | 7013f81311 | 10 months ago |
JT | 20ff29dee6 | 10 months ago |
JT | c4b4fe1e30 | 10 months ago |
JT | 03ab4346f1 | 10 months ago |
JT | 9402983b92 | 10 months ago |
JT | ae81fd0ed9 | 10 months ago |
JT | 3881c2e52d | 10 months ago |
ionite34 | 00e8705799 | 10 months ago |
ionite34 | 98cd09eb85 | 10 months ago |
ionite34 | bcb47ae8f0 | 10 months ago |
ionite34 | 4c8d7143b3 | 10 months ago |
ionite34 | a3155de7f8 | 10 months ago |
ionite34 | e4198a1516 | 10 months ago |
ionite34 | 9b3130e662 | 10 months ago |
ionite34 | 38565917bc | 10 months ago |
ionite34 | e27a00b814 | 10 months ago |
ionite34 | 0ebece2b91 | 10 months ago |
ionite34 | 62b1c796fb | 10 months ago |
ionite34 | 26e5a4009d | 10 months ago |
JT | b85e444dde | 10 months ago |
JT | 5d7f8dff53 | 10 months ago |
JT | 5a39c274ba | 10 months ago |
JT | 0f190f2cf6 | 10 months ago |
JT | bd8d028c89 | 10 months ago |
JT | 9b51e25808 | 10 months ago |
JT | f0fba442ee | 11 months ago |
ionite34 | d991f4cd62 | 11 months ago |
ionite34 | 7cc88e806b | 11 months ago |
ionite34 | 21133b758c | 11 months ago |
ionite34 | a4c895080a | 11 months ago |
ionite34 | b4779cd725 | 11 months ago |
ionite34 | df55166804 | 11 months ago |
ionite34 | 137eca4950 | 11 months ago |
ionite34 | 332f39725f | 11 months ago |
ionite34 | 5cf8b053f6 | 11 months ago |
Ionite | ea662e7937 | 11 months ago |
ionite34 | 8e728be4e3 | 11 months ago |
ionite34 | 9fe0ef8689 | 11 months ago |
ionite34 | 36848899f0 | 11 months ago |
JT | f03f7d3b60 | 11 months ago |
JT | 1daa7d19a9 | 11 months ago |
JT | 2072701c73 | 11 months ago |
JT | 4db3520a71 | 11 months ago |
JT | e5aedc1b18 | 11 months ago |
JT | 1332b8d63c | 11 months ago |
JT | cebdc28840 | 11 months ago |
JT | 7c06631fa7 | 11 months ago |
JT | fc6ef780e7 | 11 months ago |
JT | 447854c107 | 11 months ago |
Ionite | e0a2db5289 | 11 months ago |
Ionite | 679cf47ac1 | 11 months ago |
JT | 6438048c1a | 11 months ago |
JT | 04e38d2fdc | 11 months ago |
JT | 8e526922d2 | 11 months ago |
JT | 871a0cda5c | 11 months ago |
ionite34 | ae9a9d3f61 | 11 months ago |
ionite34 | bd128a0936 | 11 months ago |
ionite34 | bc463dfbbf | 11 months ago |
ionite34 | b6a5d0516c | 11 months ago |
JT | a5b17de63b | 11 months ago |
JT | 3a2223cdda | 11 months ago |
JT | f582bf4d76 | 11 months ago |
JT | a11c2b20fd | 11 months ago |
JT | 82004934aa | 11 months ago |
JT | 3c425d4a7f | 11 months ago |
JT | 177d9cb024 | 11 months ago |
Ionite | e1c25c1a5c | 11 months ago |
Ionite | 5482a043bf | 11 months ago |
JT | 45024abea2 | 11 months ago |
JT | 96571a7c9b | 11 months ago |
ionite34 | 50d25b8eb8 | 11 months ago |
ionite34 | f5819aa5b7 | 11 months ago |
ionite34 | 8129817da4 | 11 months ago |
ionite34 | 9f5aa9b815 | 11 months ago |
ionite34 | 0bdd46d110 | 11 months ago |
ionite34 | 4991960222 | 11 months ago |
Ionite | 23c1dc1096 | 11 months ago |
ionite34 | 31e234abf1 | 11 months ago |
ionite34 | fb4c3a2ac3 | 11 months ago |
ionite34 | b05aaa56ed | 11 months ago |
ionite34 | 031f0f77f7 | 11 months ago |
Ionite | 648273d6f6 | 11 months ago |
JT | 582eae0d84 | 11 months ago |
JT | a84c9110f5 | 11 months ago |
JT | b619270b55 | 11 months ago |
JT | ed543360df | 11 months ago |
JT | a763e2cd1d | 11 months ago |
ionite34 | 1d486ff294 | 11 months ago |
ionite34 | 5a6c3ca796 | 11 months ago |
ionite34 | 10b569a5ca | 11 months ago |
ionite34 | 0d03591f24 | 11 months ago |
ionite34 | 4f931624cd | 11 months ago |
ionite34 | 99d3ac5e78 | 11 months ago |
ionite34 | ff47ffa105 | 11 months ago |
ionite34 | fd4d2fd83d | 11 months ago |
JT | 8b359e045f | 11 months ago |
JT | 3c96a607a7 | 11 months ago |
ionite34 | a1655dc28d | 11 months ago |
ionite34 | e47b7da484 | 11 months ago |
ionite34 | 34ef13b513 | 11 months ago |
ionite34 | 8cc50aff0a | 11 months ago |
ionite34 | 7e3aaed219 | 11 months ago |
ionite34 | fd68b85420 | 11 months ago |
ionite34 | 8799f8464d | 11 months ago |
ionite34 | 07af9d416a | 11 months ago |
ionite34 | db2876509e | 11 months ago |
ionite34 | b2385fb6c5 | 11 months ago |
ionite34 | bf8692df8b | 11 months ago |
ionite34 | b9efb34153 | 11 months ago |
ionite34 | a98f6eb673 | 11 months ago |
ionite34 | c812aa623c | 11 months ago |
ionite34 | 2b839aca86 | 11 months ago |
ionite34 | 11a0de8cbb | 11 months ago |
ionite34 | a6f9ea48b8 | 11 months ago |
ionite34 | e3405887b1 | 11 months ago |
Ionite | 6105b49eaf | 11 months ago |
ionite34 | b24d78a428 | 11 months ago |
ionite34 | 07d7a43aa5 | 11 months ago |
ionite34 | 7fe58b6f60 | 11 months ago |
ionite34 | c023666061 | 11 months ago |
ionite34 | 60b143c89f | 11 months ago |
ionite34 | 9986d99f1a | 11 months ago |
ionite34 | bf8bb11212 | 11 months ago |
ionite34 | 71fa99ce46 | 11 months ago |
ionite34 | 8902f174ac | 11 months ago |
Ionite | 6fd3244b75 | 11 months ago |
ionite34 | 8f72aa9ebe | 11 months ago |
JT | 552af92943 | 11 months ago |
ionite34 | 2ec6efc431 | 11 months ago |
JT | 7006e43f2b | 11 months ago |
JT | 96317b3ecd | 11 months ago |
ionite34 | 6dcdf1aba9 | 11 months ago |
ionite34 | 2c32a1c43f | 11 months ago |
ionite34 | d70e5c922a | 11 months ago |
ionite34 | c0d5e2acd8 | 11 months ago |
JT | 9b878d4ef3 | 11 months ago |
JT | 1f6c947102 | 11 months ago |
JT | 6ae9acf474 | 11 months ago |
JT | 24ad22463f | 11 months ago |
Ionite | 0972e7dd4a | 11 months ago |
ionite34 | 22aa9cd68d | 11 months ago |
ionite34 | b9efeb46b2 | 11 months ago |
ionite34 | a691773375 | 11 months ago |
Ionite | d61b6ab8d5 | 11 months ago |
Ionite | 9d36794213 | 11 months ago |
Ionite | debe3cb3f4 | 11 months ago |
Ionite | 790b2043d6 | 11 months ago |
ionite34 | d12cab97b1 | 11 months ago |
ionite34 | 384d1f3746 | 11 months ago |
ionite34 | a125a0bd59 | 11 months ago |
ionite34 | 5e0a948009 | 11 months ago |
ionite34 | 7d247166ee | 11 months ago |
ionite34 | 661e387a88 | 11 months ago |
ionite34 | b60ccf9f36 | 11 months ago |
ionite34 | f94bc50a2c | 11 months ago |
ionite34 | b0a4f2e82e | 11 months ago |
ionite34 | 868edbffd1 | 11 months ago |
ionite34 | 7a939db678 | 11 months ago |
JT | 0a9b590eba | 11 months ago |
JT | 9275a7ad32 | 11 months ago |
JT | 8cd3ec1b98 | 11 months ago |
JT | eecea73aea | 11 months ago |
JT | 02bd059ad6 | 11 months ago |
JT | b0e4a09953 | 11 months ago |
JT | 7ef86731a6 | 11 months ago |
Ionite | d9ff373ca5 | 11 months ago |
Ionite | b4e4886249 | 11 months ago |
JT | b13bea5b6f | 11 months ago |
JT | a8894f9631 | 11 months ago |
ionite34 | 25a1a40f61 | 11 months ago |
ionite34 | 96f722a9da | 11 months ago |
JT | 9e28cb6965 | 11 months ago |
JT | 1bd293d8cd | 11 months ago |
Ionite | 65b3785818 | 11 months ago |
Ionite | 70c0f0d523 | 11 months ago |
JT | 1b06732c92 | 11 months ago |
JT | ad557837d8 | 11 months ago |
JT | d533be3329 | 11 months ago |
JT | d271d95ea0 | 11 months ago |
Ionite | 35f30f9518 | 11 months ago |
Ionite | ee66f0dc49 | 11 months ago |
Ionite | f6a1a241eb | 11 months ago |
Ionite | 47c663113b | 11 months ago |
JT | 816529309a | 11 months ago |
JT | 724c3f47cf | 11 months ago |
JT | ed9c5b6cfb | 11 months ago |
Ionite | 0f52bcb3a8 | 11 months ago |
Ionite | 280fc8c89f | 11 months ago |
JT | a2d8040310 | 11 months ago |
JT | ff4c347567 | 11 months ago |
JT | 2f6deecf51 | 11 months ago |
Ionite | 2e36f48f6b | 11 months ago |
Ionite | 1afe3e6347 | 11 months ago |
Ionite | a6c87a2be3 | 11 months ago |
Ionite | ed4f78a57e | 11 months ago |
Ionite | 1c37e9b4e4 | 11 months ago |
Ionite | d82f3d4843 | 11 months ago |
Ionite | 5eaaedf4dd | 11 months ago |
Ionite | 5871241d6f | 11 months ago |
Ionite | 74aac6272b | 11 months ago |
Ionite | 097845a709 | 11 months ago |
Ionite | c5bda38264 | 11 months ago |
JT | 5267d8bcef | 11 months ago |
JT | f7feb2e933 | 11 months ago |
JT | 7876df385f | 11 months ago |
JT | e92a506c79 | 11 months ago |
JT | 6154d835ee | 12 months ago |
JT | e389085ec1 | 12 months ago |
jt | b4fd64e7fc | 1 year ago |
456 changed files with 39324 additions and 4782 deletions
@ -0,0 +1,37 @@ |
|||||||
|
name: Automatic Backport |
||||||
|
|
||||||
|
on: |
||||||
|
pull_request: |
||||||
|
types: ["closed", "labeled"] |
||||||
|
|
||||||
|
jobs: |
||||||
|
backport: |
||||||
|
if: ${{ (github.event.pull_request.merged == true) && (contains(github.event.pull_request.labels.*.name, 'backport-to-main') == true) }} |
||||||
|
name: Backport PR |
||||||
|
runs-on: ubuntu-latest |
||||||
|
steps: |
||||||
|
- name: Write json |
||||||
|
id: create-json |
||||||
|
uses: jsdaniell/create-json@v1.2.3 |
||||||
|
with: |
||||||
|
name: ".backportrc.json" |
||||||
|
json: | |
||||||
|
{ |
||||||
|
"targetPRLabels": "backport", |
||||||
|
"prTitle": "[{{sourceBranch}} to {{targetBranch}}] backport: {{sourcePullRequest.title}} ({{sourcePullRequest.number}})" |
||||||
|
} |
||||||
|
|
||||||
|
- name: Backport Action |
||||||
|
uses: sorenlouv/backport-github-action@v9.3.0 |
||||||
|
with: |
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }} |
||||||
|
auto_backport_label_prefix: backport-to- |
||||||
|
|
||||||
|
- name: Info log |
||||||
|
if: ${{ success() }} |
||||||
|
run: cat ~/.backport/backport.info.log |
||||||
|
|
||||||
|
- name: Debug log |
||||||
|
if: ${{ failure() }} |
||||||
|
run: cat ~/.backport/backport.debug.log |
||||||
|
|
@ -0,0 +1,18 @@ |
|||||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||||
|
<PropertyGroup> |
||||||
|
<TargetFramework>net8.0</TargetFramework> |
||||||
|
<LangVersion>latest</LangVersion> |
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> |
||||||
|
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers> |
||||||
|
<ImplicitUsings>enable</ImplicitUsings> |
||||||
|
<Nullable>enable</Nullable> |
||||||
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport> |
||||||
|
<EnableWindowsTargeting>true</EnableWindowsTargeting> |
||||||
|
</PropertyGroup> |
||||||
|
<ItemGroup> |
||||||
|
<PackageReference Include="Avalonia" Version="$(AvaloniaVersion)" /> |
||||||
|
<PackageReference Include="SkiaSharp" Version="2.88.7" /> |
||||||
|
<PackageReference Include="DotNet.Bundle" Version="0.9.13" /> |
||||||
|
</ItemGroup> |
||||||
|
|
||||||
|
</Project> |
@ -0,0 +1,10 @@ |
|||||||
|
namespace Avalonia.Gif |
||||||
|
{ |
||||||
|
internal enum BgWorkerCommand |
||||||
|
{ |
||||||
|
Null, |
||||||
|
Play, |
||||||
|
Pause, |
||||||
|
Dispose |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,12 @@ |
|||||||
|
namespace Avalonia.Gif |
||||||
|
{ |
||||||
|
internal enum BgWorkerState |
||||||
|
{ |
||||||
|
Null, |
||||||
|
Start, |
||||||
|
Running, |
||||||
|
Paused, |
||||||
|
Complete, |
||||||
|
Dispose |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
namespace Avalonia.Gif.Decoding |
||||||
|
{ |
||||||
|
internal enum BlockTypes |
||||||
|
{ |
||||||
|
Empty = 0, |
||||||
|
Extension = 0x21, |
||||||
|
ImageDescriptor = 0x2C, |
||||||
|
Trailer = 0x3B, |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,8 @@ |
|||||||
|
namespace Avalonia.Gif.Decoding |
||||||
|
{ |
||||||
|
internal enum ExtensionType |
||||||
|
{ |
||||||
|
GraphicsControl = 0xF9, |
||||||
|
Application = 0xFF |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
namespace Avalonia.Gif.Decoding |
||||||
|
{ |
||||||
|
public enum FrameDisposal |
||||||
|
{ |
||||||
|
Unknown = 0, |
||||||
|
Leave = 1, |
||||||
|
Background = 2, |
||||||
|
Restore = 3 |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,36 @@ |
|||||||
|
using System.Runtime.InteropServices; |
||||||
|
|
||||||
|
namespace Avalonia.Gif |
||||||
|
{ |
||||||
|
[StructLayout(LayoutKind.Explicit)] |
||||||
|
public readonly struct GifColor |
||||||
|
{ |
||||||
|
[FieldOffset(3)] |
||||||
|
public readonly byte A; |
||||||
|
|
||||||
|
[FieldOffset(2)] |
||||||
|
public readonly byte R; |
||||||
|
|
||||||
|
[FieldOffset(1)] |
||||||
|
public readonly byte G; |
||||||
|
|
||||||
|
[FieldOffset(0)] |
||||||
|
public readonly byte B; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// A struct that represents a ARGB color and is aligned as |
||||||
|
/// a BGRA bytefield in memory. |
||||||
|
/// </summary> |
||||||
|
/// <param name="r">Red</param> |
||||||
|
/// <param name="g">Green</param> |
||||||
|
/// <param name="b">Blue</param> |
||||||
|
/// <param name="a">Alpha</param> |
||||||
|
public GifColor(byte r, byte g, byte b, byte a = byte.MaxValue) |
||||||
|
{ |
||||||
|
A = a; |
||||||
|
R = r; |
||||||
|
G = g; |
||||||
|
B = b; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,653 @@ |
|||||||
|
// This source file's Lempel-Ziv-Welch algorithm is derived from Chromium's Android GifPlayer |
||||||
|
// as seen here (https://github.com/chromium/chromium/blob/master/third_party/gif_player/src/jp/tomorrowkey/android/gifplayer) |
||||||
|
// Licensed under the Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0) |
||||||
|
// Copyright (C) 2015 The Gifplayer Authors. All Rights Reserved. |
||||||
|
|
||||||
|
// The rest of the source file is licensed under MIT License. |
||||||
|
// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved. |
||||||
|
|
||||||
|
using System; |
||||||
|
using System.Buffers; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Runtime.CompilerServices; |
||||||
|
using System.Runtime.InteropServices; |
||||||
|
using System.Text; |
||||||
|
using System.Threading; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Media.Imaging; |
||||||
|
using static Avalonia.Gif.Extensions.StreamExtensions; |
||||||
|
|
||||||
|
namespace Avalonia.Gif.Decoding |
||||||
|
{ |
||||||
|
public sealed class GifDecoder : IDisposable |
||||||
|
{ |
||||||
|
private static readonly ReadOnlyMemory<byte> G87AMagic = "GIF87a"u8.ToArray().AsMemory(); |
||||||
|
|
||||||
|
private static readonly ReadOnlyMemory<byte> G89AMagic = "GIF89a"u8.ToArray().AsMemory(); |
||||||
|
|
||||||
|
private static readonly ReadOnlyMemory<byte> NetscapeMagic = "NETSCAPE2.0"u8.ToArray().AsMemory(); |
||||||
|
|
||||||
|
private static readonly TimeSpan FrameDelayThreshold = TimeSpan.FromMilliseconds(10); |
||||||
|
private static readonly TimeSpan FrameDelayDefault = TimeSpan.FromMilliseconds(100); |
||||||
|
private static readonly GifColor TransparentColor = new(0, 0, 0, 0); |
||||||
|
private static readonly int MaxTempBuf = 768; |
||||||
|
private static readonly int MaxStackSize = 4096; |
||||||
|
private static readonly int MaxBits = 4097; |
||||||
|
|
||||||
|
private readonly Stream _fileStream; |
||||||
|
private readonly CancellationToken _currentCtsToken; |
||||||
|
private readonly bool _hasFrameBackups; |
||||||
|
|
||||||
|
private int _gctSize, |
||||||
|
_bgIndex, |
||||||
|
_prevFrame = -1, |
||||||
|
_backupFrame = -1; |
||||||
|
private bool _gctUsed; |
||||||
|
|
||||||
|
private GifRect _gifDimensions; |
||||||
|
|
||||||
|
// private ulong _globalColorTable; |
||||||
|
private readonly int _backBufferBytes; |
||||||
|
private GifColor[] _bitmapBackBuffer; |
||||||
|
|
||||||
|
private short[] _prefixBuf; |
||||||
|
private byte[] _suffixBuf; |
||||||
|
private byte[] _pixelStack; |
||||||
|
private byte[] _indexBuf; |
||||||
|
private byte[] _backupFrameIndexBuf; |
||||||
|
private volatile bool _hasNewFrame; |
||||||
|
|
||||||
|
public GifHeader Header { get; private set; } |
||||||
|
|
||||||
|
public readonly List<GifFrame> Frames = new(); |
||||||
|
|
||||||
|
public PixelSize Size => new PixelSize(Header.Dimensions.Width, Header.Dimensions.Height); |
||||||
|
|
||||||
|
public GifDecoder(Stream fileStream, CancellationToken currentCtsToken) |
||||||
|
{ |
||||||
|
_fileStream = fileStream; |
||||||
|
_currentCtsToken = currentCtsToken; |
||||||
|
|
||||||
|
ProcessHeaderData(); |
||||||
|
ProcessFrameData(); |
||||||
|
|
||||||
|
Header.IterationCount = Header.Iterations switch |
||||||
|
{ |
||||||
|
-1 => new GifRepeatBehavior { Count = 1 }, |
||||||
|
0 => new GifRepeatBehavior { LoopForever = true }, |
||||||
|
> 0 => new GifRepeatBehavior { Count = Header.Iterations }, |
||||||
|
_ => Header.IterationCount |
||||||
|
}; |
||||||
|
|
||||||
|
var pixelCount = _gifDimensions.TotalPixels; |
||||||
|
|
||||||
|
_hasFrameBackups = Frames.Any(f => f.FrameDisposalMethod == FrameDisposal.Restore); |
||||||
|
|
||||||
|
_bitmapBackBuffer = new GifColor[pixelCount]; |
||||||
|
_indexBuf = new byte[pixelCount]; |
||||||
|
|
||||||
|
if (_hasFrameBackups) |
||||||
|
_backupFrameIndexBuf = new byte[pixelCount]; |
||||||
|
|
||||||
|
_prefixBuf = new short[MaxStackSize]; |
||||||
|
_suffixBuf = new byte[MaxStackSize]; |
||||||
|
_pixelStack = new byte[MaxStackSize + 1]; |
||||||
|
|
||||||
|
_backBufferBytes = pixelCount * Marshal.SizeOf(typeof(GifColor)); |
||||||
|
} |
||||||
|
|
||||||
|
public void Dispose() |
||||||
|
{ |
||||||
|
Frames.Clear(); |
||||||
|
|
||||||
|
_bitmapBackBuffer = null; |
||||||
|
_prefixBuf = null; |
||||||
|
_suffixBuf = null; |
||||||
|
_pixelStack = null; |
||||||
|
_indexBuf = null; |
||||||
|
_backupFrameIndexBuf = null; |
||||||
|
} |
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||||
|
private int PixCoord(int x, int y) => x + y * _gifDimensions.Width; |
||||||
|
|
||||||
|
static readonly (int Start, int Step)[] Pass = { (0, 8), (4, 8), (2, 4), (1, 2) }; |
||||||
|
|
||||||
|
private void ClearImage() |
||||||
|
{ |
||||||
|
Array.Fill(_bitmapBackBuffer, TransparentColor); |
||||||
|
//ClearArea(_gifDimensions); |
||||||
|
|
||||||
|
_prevFrame = -1; |
||||||
|
_backupFrame = -1; |
||||||
|
} |
||||||
|
|
||||||
|
public void RenderFrame(int fIndex, WriteableBitmap writeableBitmap, bool forceClear = false) |
||||||
|
{ |
||||||
|
if (_currentCtsToken.IsCancellationRequested) |
||||||
|
return; |
||||||
|
|
||||||
|
if (fIndex < 0 | fIndex >= Frames.Count) |
||||||
|
return; |
||||||
|
|
||||||
|
if (_prevFrame == fIndex) |
||||||
|
return; |
||||||
|
|
||||||
|
if (fIndex == 0 || forceClear || fIndex < _prevFrame) |
||||||
|
ClearImage(); |
||||||
|
|
||||||
|
DisposePreviousFrame(); |
||||||
|
|
||||||
|
_prevFrame++; |
||||||
|
|
||||||
|
// render intermediate frame |
||||||
|
for (int idx = _prevFrame; idx < fIndex; ++idx) |
||||||
|
{ |
||||||
|
var prevFrame = Frames[idx]; |
||||||
|
|
||||||
|
if (prevFrame.FrameDisposalMethod == FrameDisposal.Restore) |
||||||
|
continue; |
||||||
|
|
||||||
|
if (prevFrame.FrameDisposalMethod == FrameDisposal.Background) |
||||||
|
{ |
||||||
|
ClearArea(prevFrame.Dimensions); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
RenderFrameAt(idx, writeableBitmap); |
||||||
|
} |
||||||
|
|
||||||
|
RenderFrameAt(fIndex, writeableBitmap); |
||||||
|
} |
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||||
|
private void RenderFrameAt(int idx, WriteableBitmap writeableBitmap) |
||||||
|
{ |
||||||
|
var tmpB = ArrayPool<byte>.Shared.Rent(MaxTempBuf); |
||||||
|
|
||||||
|
var curFrame = Frames[idx]; |
||||||
|
DecompressFrameToIndexBuffer(curFrame, _indexBuf, tmpB); |
||||||
|
|
||||||
|
if (_hasFrameBackups & curFrame.ShouldBackup) |
||||||
|
{ |
||||||
|
Buffer.BlockCopy(_indexBuf, 0, _backupFrameIndexBuf, 0, curFrame.Dimensions.TotalPixels); |
||||||
|
_backupFrame = idx; |
||||||
|
} |
||||||
|
|
||||||
|
DrawFrame(curFrame, _indexBuf); |
||||||
|
|
||||||
|
_prevFrame = idx; |
||||||
|
_hasNewFrame = true; |
||||||
|
|
||||||
|
using var lockedBitmap = writeableBitmap.Lock(); |
||||||
|
WriteBackBufToFb(lockedBitmap.Address); |
||||||
|
|
||||||
|
ArrayPool<byte>.Shared.Return(tmpB); |
||||||
|
} |
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||||
|
private void DrawFrame(GifFrame curFrame, Memory<byte> frameIndexSpan) |
||||||
|
{ |
||||||
|
var activeColorTable = curFrame.IsLocalColorTableUsed ? curFrame.LocalColorTable : Header.GlobarColorTable; |
||||||
|
|
||||||
|
var cX = curFrame.Dimensions.X; |
||||||
|
var cY = curFrame.Dimensions.Y; |
||||||
|
var cH = curFrame.Dimensions.Height; |
||||||
|
var cW = curFrame.Dimensions.Width; |
||||||
|
var tC = curFrame.TransparentColorIndex; |
||||||
|
var hT = curFrame.HasTransparency; |
||||||
|
|
||||||
|
if (curFrame.IsInterlaced) |
||||||
|
{ |
||||||
|
for (var i = 0; i < 4; i++) |
||||||
|
{ |
||||||
|
var curPass = Pass[i]; |
||||||
|
var y = curPass.Start; |
||||||
|
while (y < cH) |
||||||
|
{ |
||||||
|
DrawRow(y); |
||||||
|
y += curPass.Step; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
for (var i = 0; i < cH; i++) |
||||||
|
DrawRow(i); |
||||||
|
} |
||||||
|
|
||||||
|
//for (var row = 0; row < cH; row++) |
||||||
|
void DrawRow(int row) |
||||||
|
{ |
||||||
|
// Get the starting point of the current row on frame's index stream. |
||||||
|
var indexOffset = row * cW; |
||||||
|
|
||||||
|
// Get the target backbuffer offset from the frames coords. |
||||||
|
var targetOffset = PixCoord(cX, row + cY); |
||||||
|
var len = _bitmapBackBuffer.Length; |
||||||
|
|
||||||
|
for (var i = 0; i < cW; i++) |
||||||
|
{ |
||||||
|
var indexColor = frameIndexSpan.Span[indexOffset + i]; |
||||||
|
|
||||||
|
if (activeColorTable == null || targetOffset >= len || indexColor > activeColorTable.Length) |
||||||
|
return; |
||||||
|
|
||||||
|
if (!(hT & indexColor == tC)) |
||||||
|
_bitmapBackBuffer[targetOffset] = activeColorTable[indexColor]; |
||||||
|
|
||||||
|
targetOffset++; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||||
|
private void DisposePreviousFrame() |
||||||
|
{ |
||||||
|
if (_prevFrame == -1) |
||||||
|
return; |
||||||
|
|
||||||
|
var prevFrame = Frames[_prevFrame]; |
||||||
|
|
||||||
|
switch (prevFrame.FrameDisposalMethod) |
||||||
|
{ |
||||||
|
case FrameDisposal.Background: |
||||||
|
ClearArea(prevFrame.Dimensions); |
||||||
|
break; |
||||||
|
case FrameDisposal.Restore: |
||||||
|
if (_hasFrameBackups && _backupFrame != -1) |
||||||
|
DrawFrame(Frames[_backupFrame], _backupFrameIndexBuf); |
||||||
|
else |
||||||
|
ClearArea(prevFrame.Dimensions); |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||||
|
private void ClearArea(GifRect area) |
||||||
|
{ |
||||||
|
for (var y = 0; y < area.Height; y++) |
||||||
|
{ |
||||||
|
var targetOffset = PixCoord(area.X, y + area.Y); |
||||||
|
for (var x = 0; x < area.Width; x++) |
||||||
|
_bitmapBackBuffer[targetOffset + x] = TransparentColor; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||||
|
private void DecompressFrameToIndexBuffer(GifFrame curFrame, Span<byte> indexSpan, byte[] tempBuf) |
||||||
|
{ |
||||||
|
_fileStream.Position = curFrame.LzwStreamPosition; |
||||||
|
var totalPixels = curFrame.Dimensions.TotalPixels; |
||||||
|
|
||||||
|
// Initialize GIF data stream decoder. |
||||||
|
var dataSize = curFrame.LzwMinCodeSize; |
||||||
|
var clear = 1 << dataSize; |
||||||
|
var endOfInformation = clear + 1; |
||||||
|
var available = clear + 2; |
||||||
|
var oldCode = -1; |
||||||
|
var codeSize = dataSize + 1; |
||||||
|
var codeMask = (1 << codeSize) - 1; |
||||||
|
|
||||||
|
for (var code = 0; code < clear; code++) |
||||||
|
{ |
||||||
|
_prefixBuf[code] = 0; |
||||||
|
_suffixBuf[code] = (byte)code; |
||||||
|
} |
||||||
|
|
||||||
|
// Decode GIF pixel stream. |
||||||
|
int bits, |
||||||
|
first, |
||||||
|
top, |
||||||
|
pixelIndex; |
||||||
|
var datum = bits = first = top = pixelIndex = 0; |
||||||
|
|
||||||
|
while (pixelIndex < totalPixels) |
||||||
|
{ |
||||||
|
var blockSize = _fileStream.ReadBlock(tempBuf); |
||||||
|
|
||||||
|
if (blockSize == 0) |
||||||
|
break; |
||||||
|
|
||||||
|
var blockPos = 0; |
||||||
|
|
||||||
|
while (blockPos < blockSize) |
||||||
|
{ |
||||||
|
datum += tempBuf[blockPos] << bits; |
||||||
|
blockPos++; |
||||||
|
|
||||||
|
bits += 8; |
||||||
|
|
||||||
|
while (bits >= codeSize) |
||||||
|
{ |
||||||
|
// Get the next code. |
||||||
|
var code = datum & codeMask; |
||||||
|
datum >>= codeSize; |
||||||
|
bits -= codeSize; |
||||||
|
|
||||||
|
// Interpret the code |
||||||
|
if (code == clear) |
||||||
|
{ |
||||||
|
// Reset decoder. |
||||||
|
codeSize = dataSize + 1; |
||||||
|
codeMask = (1 << codeSize) - 1; |
||||||
|
available = clear + 2; |
||||||
|
oldCode = -1; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Check for explicit end-of-stream |
||||||
|
if (code == endOfInformation) |
||||||
|
return; |
||||||
|
|
||||||
|
if (oldCode == -1) |
||||||
|
{ |
||||||
|
indexSpan[pixelIndex++] = _suffixBuf[code]; |
||||||
|
oldCode = code; |
||||||
|
first = code; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
var inCode = code; |
||||||
|
if (code >= available) |
||||||
|
{ |
||||||
|
_pixelStack[top++] = (byte)first; |
||||||
|
code = oldCode; |
||||||
|
|
||||||
|
if (top == MaxBits) |
||||||
|
ThrowException(); |
||||||
|
} |
||||||
|
|
||||||
|
while (code >= clear) |
||||||
|
{ |
||||||
|
if (code >= MaxBits || code == _prefixBuf[code]) |
||||||
|
ThrowException(); |
||||||
|
|
||||||
|
_pixelStack[top++] = _suffixBuf[code]; |
||||||
|
code = _prefixBuf[code]; |
||||||
|
|
||||||
|
if (top == MaxBits) |
||||||
|
ThrowException(); |
||||||
|
} |
||||||
|
|
||||||
|
first = _suffixBuf[code]; |
||||||
|
_pixelStack[top++] = (byte)first; |
||||||
|
|
||||||
|
// Add new code to the dictionary |
||||||
|
if (available < MaxStackSize) |
||||||
|
{ |
||||||
|
_prefixBuf[available] = (short)oldCode; |
||||||
|
_suffixBuf[available] = (byte)first; |
||||||
|
available++; |
||||||
|
|
||||||
|
if ((available & codeMask) == 0 && available < MaxStackSize) |
||||||
|
{ |
||||||
|
codeSize++; |
||||||
|
codeMask += available; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
oldCode = inCode; |
||||||
|
|
||||||
|
// Drain the pixel stack. |
||||||
|
do |
||||||
|
{ |
||||||
|
indexSpan[pixelIndex++] = _pixelStack[--top]; |
||||||
|
} while (top > 0); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
while (pixelIndex < totalPixels) |
||||||
|
indexSpan[pixelIndex++] = 0; // clear missing pixels |
||||||
|
|
||||||
|
void ThrowException() => throw new LzwDecompressionException(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Directly copies the <see cref="GifColor"/> struct array to a bitmap IntPtr. |
||||||
|
/// </summary> |
||||||
|
private void WriteBackBufToFb(IntPtr targetPointer) |
||||||
|
{ |
||||||
|
if (_currentCtsToken.IsCancellationRequested) |
||||||
|
return; |
||||||
|
|
||||||
|
if (!(_hasNewFrame & _bitmapBackBuffer != null)) |
||||||
|
return; |
||||||
|
|
||||||
|
unsafe |
||||||
|
{ |
||||||
|
fixed (void* src = &_bitmapBackBuffer[0]) |
||||||
|
Buffer.MemoryCopy(src, targetPointer.ToPointer(), (uint)_backBufferBytes, (uint)_backBufferBytes); |
||||||
|
_hasNewFrame = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Processes GIF Header. |
||||||
|
/// </summary> |
||||||
|
private void ProcessHeaderData() |
||||||
|
{ |
||||||
|
var str = _fileStream; |
||||||
|
var tmpB = ArrayPool<byte>.Shared.Rent(MaxTempBuf); |
||||||
|
var tempBuf = tmpB.AsSpan(); |
||||||
|
|
||||||
|
var _ = str.Read(tmpB, 0, 6); |
||||||
|
|
||||||
|
if (!tempBuf[..3].SequenceEqual(G87AMagic[..3].Span)) |
||||||
|
throw new InvalidGifStreamException("Not a GIF stream."); |
||||||
|
|
||||||
|
if (!(tempBuf[..6].SequenceEqual(G87AMagic.Span) | tempBuf[..6].SequenceEqual(G89AMagic.Span))) |
||||||
|
throw new InvalidGifStreamException( |
||||||
|
"Unsupported GIF Version: " + Encoding.ASCII.GetString(tempBuf[..6].ToArray()) |
||||||
|
); |
||||||
|
|
||||||
|
ProcessScreenDescriptor(tmpB); |
||||||
|
|
||||||
|
Header = new GifHeader |
||||||
|
{ |
||||||
|
Dimensions = _gifDimensions, |
||||||
|
HasGlobalColorTable = _gctUsed, |
||||||
|
// GlobalColorTableCacheID = _globalColorTable, |
||||||
|
GlobarColorTable = ProcessColorTable(ref str, tmpB, _gctSize), |
||||||
|
GlobalColorTableSize = _gctSize, |
||||||
|
BackgroundColorIndex = _bgIndex, |
||||||
|
HeaderSize = _fileStream.Position |
||||||
|
}; |
||||||
|
|
||||||
|
ArrayPool<byte>.Shared.Return(tmpB); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Parses colors from file stream to target color table. |
||||||
|
/// </summary> |
||||||
|
private static GifColor[] ProcessColorTable(ref Stream stream, byte[] rawBufSpan, int nColors) |
||||||
|
{ |
||||||
|
var nBytes = 3 * nColors; |
||||||
|
var target = new GifColor[nColors]; |
||||||
|
|
||||||
|
var n = stream.Read(rawBufSpan, 0, nBytes); |
||||||
|
|
||||||
|
if (n < nBytes) |
||||||
|
throw new InvalidOperationException("Wrong color table bytes."); |
||||||
|
|
||||||
|
int i = 0, |
||||||
|
j = 0; |
||||||
|
|
||||||
|
while (i < nColors) |
||||||
|
{ |
||||||
|
var r = rawBufSpan[j++]; |
||||||
|
var g = rawBufSpan[j++]; |
||||||
|
var b = rawBufSpan[j++]; |
||||||
|
target[i++] = new GifColor(r, g, b); |
||||||
|
} |
||||||
|
|
||||||
|
return target; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Parses screen and other GIF descriptors. |
||||||
|
/// </summary> |
||||||
|
private void ProcessScreenDescriptor(byte[] tempBuf) |
||||||
|
{ |
||||||
|
var width = _fileStream.ReadUShortS(tempBuf); |
||||||
|
var height = _fileStream.ReadUShortS(tempBuf); |
||||||
|
|
||||||
|
var packed = _fileStream.ReadByteS(tempBuf); |
||||||
|
|
||||||
|
_gctUsed = (packed & 0x80) != 0; |
||||||
|
_gctSize = 2 << (packed & 7); |
||||||
|
_bgIndex = _fileStream.ReadByteS(tempBuf); |
||||||
|
|
||||||
|
_gifDimensions = new GifRect(0, 0, width, height); |
||||||
|
_fileStream.Skip(1); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Parses all frame data. |
||||||
|
/// </summary> |
||||||
|
private void ProcessFrameData() |
||||||
|
{ |
||||||
|
_fileStream.Position = Header.HeaderSize; |
||||||
|
|
||||||
|
var tempBuf = ArrayPool<byte>.Shared.Rent(MaxTempBuf); |
||||||
|
|
||||||
|
var terminate = false; |
||||||
|
var curFrame = 0; |
||||||
|
|
||||||
|
Frames.Add(new GifFrame()); |
||||||
|
|
||||||
|
do |
||||||
|
{ |
||||||
|
var blockType = (BlockTypes)_fileStream.ReadByteS(tempBuf); |
||||||
|
|
||||||
|
switch (blockType) |
||||||
|
{ |
||||||
|
case BlockTypes.Empty: |
||||||
|
break; |
||||||
|
|
||||||
|
case BlockTypes.Extension: |
||||||
|
ProcessExtensions(ref curFrame, tempBuf); |
||||||
|
break; |
||||||
|
|
||||||
|
case BlockTypes.ImageDescriptor: |
||||||
|
ProcessImageDescriptor(ref curFrame, tempBuf); |
||||||
|
_fileStream.SkipBlocks(tempBuf); |
||||||
|
break; |
||||||
|
|
||||||
|
case BlockTypes.Trailer: |
||||||
|
Frames.RemoveAt(Frames.Count - 1); |
||||||
|
terminate = true; |
||||||
|
break; |
||||||
|
|
||||||
|
default: |
||||||
|
_fileStream.SkipBlocks(tempBuf); |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
// Break the loop when the stream is not valid anymore. |
||||||
|
if (_fileStream.Position >= _fileStream.Length & terminate == false) |
||||||
|
throw new InvalidProgramException("Reach the end of the filestream without trailer block."); |
||||||
|
} while (!terminate); |
||||||
|
|
||||||
|
ArrayPool<byte>.Shared.Return(tempBuf); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Parses GIF Image Descriptor Block. |
||||||
|
/// </summary> |
||||||
|
private void ProcessImageDescriptor(ref int curFrame, byte[] tempBuf) |
||||||
|
{ |
||||||
|
var str = _fileStream; |
||||||
|
var currentFrame = Frames[curFrame]; |
||||||
|
|
||||||
|
// Parse frame dimensions. |
||||||
|
var frameX = str.ReadUShortS(tempBuf); |
||||||
|
var frameY = str.ReadUShortS(tempBuf); |
||||||
|
var frameW = str.ReadUShortS(tempBuf); |
||||||
|
var frameH = str.ReadUShortS(tempBuf); |
||||||
|
|
||||||
|
frameW = (ushort)Math.Min(frameW, _gifDimensions.Width - frameX); |
||||||
|
frameH = (ushort)Math.Min(frameH, _gifDimensions.Height - frameY); |
||||||
|
|
||||||
|
currentFrame.Dimensions = new GifRect(frameX, frameY, frameW, frameH); |
||||||
|
|
||||||
|
// Unpack interlace and lct info. |
||||||
|
var packed = str.ReadByteS(tempBuf); |
||||||
|
currentFrame.IsInterlaced = (packed & 0x40) != 0; |
||||||
|
currentFrame.IsLocalColorTableUsed = (packed & 0x80) != 0; |
||||||
|
currentFrame.LocalColorTableSize = (int)Math.Pow(2, (packed & 0x07) + 1); |
||||||
|
|
||||||
|
if (currentFrame.IsLocalColorTableUsed) |
||||||
|
currentFrame.LocalColorTable = ProcessColorTable(ref str, tempBuf, currentFrame.LocalColorTableSize); |
||||||
|
|
||||||
|
currentFrame.LzwMinCodeSize = str.ReadByteS(tempBuf); |
||||||
|
currentFrame.LzwStreamPosition = str.Position; |
||||||
|
|
||||||
|
curFrame += 1; |
||||||
|
Frames.Add(new GifFrame()); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Parses GIF Extension Blocks. |
||||||
|
/// </summary> |
||||||
|
private void ProcessExtensions(ref int curFrame, byte[] tempBuf) |
||||||
|
{ |
||||||
|
var extType = (ExtensionType)_fileStream.ReadByteS(tempBuf); |
||||||
|
|
||||||
|
switch (extType) |
||||||
|
{ |
||||||
|
case ExtensionType.GraphicsControl: |
||||||
|
|
||||||
|
_fileStream.ReadBlock(tempBuf); |
||||||
|
var currentFrame = Frames[curFrame]; |
||||||
|
var packed = tempBuf[0]; |
||||||
|
|
||||||
|
currentFrame.FrameDisposalMethod = (FrameDisposal)((packed & 0x1c) >> 2); |
||||||
|
|
||||||
|
if ( |
||||||
|
currentFrame.FrameDisposalMethod != FrameDisposal.Restore |
||||||
|
&& currentFrame.FrameDisposalMethod != FrameDisposal.Background |
||||||
|
) |
||||||
|
currentFrame.ShouldBackup = true; |
||||||
|
|
||||||
|
currentFrame.HasTransparency = (packed & 1) != 0; |
||||||
|
|
||||||
|
currentFrame.FrameDelay = TimeSpan.FromMilliseconds(SpanToShort(tempBuf.AsSpan(1)) * 10); |
||||||
|
|
||||||
|
if (currentFrame.FrameDelay <= FrameDelayThreshold) |
||||||
|
currentFrame.FrameDelay = FrameDelayDefault; |
||||||
|
|
||||||
|
currentFrame.TransparentColorIndex = tempBuf[3]; |
||||||
|
break; |
||||||
|
|
||||||
|
case ExtensionType.Application: |
||||||
|
var blockLen = _fileStream.ReadBlock(tempBuf); |
||||||
|
var _ = tempBuf.AsSpan(0, blockLen); |
||||||
|
var blockHeader = tempBuf.AsSpan(0, NetscapeMagic.Length); |
||||||
|
|
||||||
|
if (blockHeader.SequenceEqual(NetscapeMagic.Span)) |
||||||
|
{ |
||||||
|
var count = 1; |
||||||
|
|
||||||
|
while (count > 0) |
||||||
|
count = _fileStream.ReadBlock(tempBuf); |
||||||
|
|
||||||
|
var iterationCount = SpanToShort(tempBuf.AsSpan(1)); |
||||||
|
|
||||||
|
Header.Iterations = iterationCount; |
||||||
|
} |
||||||
|
else |
||||||
|
_fileStream.SkipBlocks(tempBuf); |
||||||
|
|
||||||
|
break; |
||||||
|
|
||||||
|
default: |
||||||
|
_fileStream.SkipBlocks(tempBuf); |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
using System; |
||||||
|
|
||||||
|
namespace Avalonia.Gif.Decoding |
||||||
|
{ |
||||||
|
public class GifFrame |
||||||
|
{ |
||||||
|
public bool HasTransparency, |
||||||
|
IsInterlaced, |
||||||
|
IsLocalColorTableUsed; |
||||||
|
public byte TransparentColorIndex; |
||||||
|
public int LzwMinCodeSize, |
||||||
|
LocalColorTableSize; |
||||||
|
public long LzwStreamPosition; |
||||||
|
public TimeSpan FrameDelay; |
||||||
|
public FrameDisposal FrameDisposalMethod; |
||||||
|
public bool ShouldBackup; |
||||||
|
public GifRect Dimensions; |
||||||
|
public GifColor[] LocalColorTable; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,19 @@ |
|||||||
|
// Licensed under the MIT License. |
||||||
|
// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved. |
||||||
|
|
||||||
|
namespace Avalonia.Gif.Decoding |
||||||
|
{ |
||||||
|
public class GifHeader |
||||||
|
{ |
||||||
|
public bool HasGlobalColorTable; |
||||||
|
public int GlobalColorTableSize; |
||||||
|
public ulong GlobalColorTableCacheId; |
||||||
|
public int BackgroundColorIndex; |
||||||
|
public long HeaderSize; |
||||||
|
internal int Iterations = -1; |
||||||
|
public GifRepeatBehavior IterationCount; |
||||||
|
public GifRect Dimensions; |
||||||
|
private GifColor[] _globarColorTable; |
||||||
|
public GifColor[] GlobarColorTable; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,43 @@ |
|||||||
|
namespace Avalonia.Gif.Decoding |
||||||
|
{ |
||||||
|
public readonly struct GifRect |
||||||
|
{ |
||||||
|
public int X { get; } |
||||||
|
public int Y { get; } |
||||||
|
public int Width { get; } |
||||||
|
public int Height { get; } |
||||||
|
public int TotalPixels { get; } |
||||||
|
|
||||||
|
public GifRect(int x, int y, int width, int height) |
||||||
|
{ |
||||||
|
X = x; |
||||||
|
Y = y; |
||||||
|
Width = width; |
||||||
|
Height = height; |
||||||
|
TotalPixels = width * height; |
||||||
|
} |
||||||
|
|
||||||
|
public static bool operator ==(GifRect a, GifRect b) |
||||||
|
{ |
||||||
|
return a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height; |
||||||
|
} |
||||||
|
|
||||||
|
public static bool operator !=(GifRect a, GifRect b) |
||||||
|
{ |
||||||
|
return !(a == b); |
||||||
|
} |
||||||
|
|
||||||
|
public override bool Equals(object obj) |
||||||
|
{ |
||||||
|
if (obj == null || GetType() != obj.GetType()) |
||||||
|
return false; |
||||||
|
|
||||||
|
return this == (GifRect)obj; |
||||||
|
} |
||||||
|
|
||||||
|
public override int GetHashCode() |
||||||
|
{ |
||||||
|
return X.GetHashCode() ^ Y.GetHashCode() | Width.GetHashCode() ^ Height.GetHashCode(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,8 @@ |
|||||||
|
namespace Avalonia.Gif.Decoding |
||||||
|
{ |
||||||
|
public class GifRepeatBehavior |
||||||
|
{ |
||||||
|
public bool LoopForever { get; set; } |
||||||
|
public int? Count { get; set; } |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,23 @@ |
|||||||
|
// Licensed under the MIT License. |
||||||
|
// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved. |
||||||
|
|
||||||
|
using System; |
||||||
|
using System.Runtime.Serialization; |
||||||
|
|
||||||
|
namespace Avalonia.Gif.Decoding |
||||||
|
{ |
||||||
|
[Serializable] |
||||||
|
public class InvalidGifStreamException : Exception |
||||||
|
{ |
||||||
|
public InvalidGifStreamException() { } |
||||||
|
|
||||||
|
public InvalidGifStreamException(string message) |
||||||
|
: base(message) { } |
||||||
|
|
||||||
|
public InvalidGifStreamException(string message, Exception innerException) |
||||||
|
: base(message, innerException) { } |
||||||
|
|
||||||
|
protected InvalidGifStreamException(SerializationInfo info, StreamingContext context) |
||||||
|
: base(info, context) { } |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,23 @@ |
|||||||
|
// Licensed under the MIT License. |
||||||
|
// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved. |
||||||
|
|
||||||
|
using System; |
||||||
|
using System.Runtime.Serialization; |
||||||
|
|
||||||
|
namespace Avalonia.Gif.Decoding |
||||||
|
{ |
||||||
|
[Serializable] |
||||||
|
public class LzwDecompressionException : Exception |
||||||
|
{ |
||||||
|
public LzwDecompressionException() { } |
||||||
|
|
||||||
|
public LzwDecompressionException(string message) |
||||||
|
: base(message) { } |
||||||
|
|
||||||
|
public LzwDecompressionException(string message, Exception innerException) |
||||||
|
: base(message, innerException) { } |
||||||
|
|
||||||
|
protected LzwDecompressionException(SerializationInfo info, StreamingContext context) |
||||||
|
: base(info, context) { } |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,81 @@ |
|||||||
|
using System; |
||||||
|
using System.Diagnostics; |
||||||
|
using System.IO; |
||||||
|
using System.Runtime.CompilerServices; |
||||||
|
|
||||||
|
namespace Avalonia.Gif.Extensions |
||||||
|
{ |
||||||
|
[DebuggerStepThrough] |
||||||
|
internal static class StreamExtensions |
||||||
|
{ |
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||||
|
public static ushort SpanToShort(Span<byte> b) => (ushort)(b[0] | (b[1] << 8)); |
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||||
|
public static void Skip(this Stream stream, long count) |
||||||
|
{ |
||||||
|
stream.Position += count; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Read a Gif block from stream while advancing the position. |
||||||
|
/// </summary> |
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||||
|
public static int ReadBlock(this Stream stream, byte[] tempBuf) |
||||||
|
{ |
||||||
|
stream.Read(tempBuf, 0, 1); |
||||||
|
|
||||||
|
var blockLength = (int)tempBuf[0]; |
||||||
|
|
||||||
|
if (blockLength > 0) |
||||||
|
stream.Read(tempBuf, 0, blockLength); |
||||||
|
|
||||||
|
// Guard against infinite loop. |
||||||
|
if (stream.Position >= stream.Length) |
||||||
|
throw new InvalidGifStreamException("Reach the end of the filestream without trailer block."); |
||||||
|
|
||||||
|
return blockLength; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Skips GIF blocks until it encounters an empty block. |
||||||
|
/// </summary> |
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||||
|
public static void SkipBlocks(this Stream stream, byte[] tempBuf) |
||||||
|
{ |
||||||
|
int blockLength; |
||||||
|
do |
||||||
|
{ |
||||||
|
stream.Read(tempBuf, 0, 1); |
||||||
|
|
||||||
|
blockLength = tempBuf[0]; |
||||||
|
stream.Position += blockLength; |
||||||
|
|
||||||
|
// Guard against infinite loop. |
||||||
|
if (stream.Position >= stream.Length) |
||||||
|
throw new InvalidGifStreamException("Reach the end of the filestream without trailer block."); |
||||||
|
} while (blockLength > 0); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Read a <see cref="ushort"/> from stream by providing a temporary buffer. |
||||||
|
/// </summary> |
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||||
|
public static ushort ReadUShortS(this Stream stream, byte[] tempBuf) |
||||||
|
{ |
||||||
|
stream.Read(tempBuf, 0, 2); |
||||||
|
return SpanToShort(tempBuf); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Read a <see cref="ushort"/> from stream by providing a temporary buffer. |
||||||
|
/// </summary> |
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||||
|
public static byte ReadByteS(this Stream stream, byte[] tempBuf) |
||||||
|
{ |
||||||
|
stream.Read(tempBuf, 0, 1); |
||||||
|
var finalVal = tempBuf[0]; |
||||||
|
return finalVal; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,297 @@ |
|||||||
|
using System; |
||||||
|
using System.IO; |
||||||
|
using System.Numerics; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Animation; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Logging; |
||||||
|
using Avalonia.Media; |
||||||
|
using Avalonia.Rendering.Composition; |
||||||
|
using Avalonia.VisualTree; |
||||||
|
|
||||||
|
namespace Avalonia.Gif |
||||||
|
{ |
||||||
|
public class GifImage : Control |
||||||
|
{ |
||||||
|
public static readonly StyledProperty<string> SourceUriRawProperty = AvaloniaProperty.Register< |
||||||
|
GifImage, |
||||||
|
string |
||||||
|
>("SourceUriRaw"); |
||||||
|
|
||||||
|
public static readonly StyledProperty<Uri> SourceUriProperty = AvaloniaProperty.Register<GifImage, Uri>( |
||||||
|
"SourceUri" |
||||||
|
); |
||||||
|
|
||||||
|
public static readonly StyledProperty<Stream> SourceStreamProperty = AvaloniaProperty.Register< |
||||||
|
GifImage, |
||||||
|
Stream |
||||||
|
>("SourceStream"); |
||||||
|
|
||||||
|
public static readonly StyledProperty<IterationCount> IterationCountProperty = AvaloniaProperty.Register< |
||||||
|
GifImage, |
||||||
|
IterationCount |
||||||
|
>("IterationCount", IterationCount.Infinite); |
||||||
|
|
||||||
|
private IGifInstance? _gifInstance; |
||||||
|
|
||||||
|
public static readonly StyledProperty<StretchDirection> StretchDirectionProperty = AvaloniaProperty.Register< |
||||||
|
GifImage, |
||||||
|
StretchDirection |
||||||
|
>("StretchDirection"); |
||||||
|
|
||||||
|
public static readonly StyledProperty<Stretch> StretchProperty = AvaloniaProperty.Register<GifImage, Stretch>( |
||||||
|
"Stretch" |
||||||
|
); |
||||||
|
|
||||||
|
private CompositionCustomVisual? _customVisual; |
||||||
|
|
||||||
|
private object? _initialSource = null; |
||||||
|
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) |
||||||
|
{ |
||||||
|
switch (change.Property.Name) |
||||||
|
{ |
||||||
|
case nameof(SourceUriRaw): |
||||||
|
case nameof(SourceUri): |
||||||
|
case nameof(SourceStream): |
||||||
|
SourceChanged(change); |
||||||
|
break; |
||||||
|
case nameof(Stretch): |
||||||
|
case nameof(StretchDirection): |
||||||
|
InvalidateArrange(); |
||||||
|
InvalidateMeasure(); |
||||||
|
Update(); |
||||||
|
break; |
||||||
|
case nameof(IterationCount): |
||||||
|
IterationCountChanged(change); |
||||||
|
break; |
||||||
|
case nameof(Bounds): |
||||||
|
Update(); |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
base.OnPropertyChanged(change); |
||||||
|
} |
||||||
|
|
||||||
|
public string SourceUriRaw |
||||||
|
{ |
||||||
|
get => GetValue(SourceUriRawProperty); |
||||||
|
set => SetValue(SourceUriRawProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public Uri SourceUri |
||||||
|
{ |
||||||
|
get => GetValue(SourceUriProperty); |
||||||
|
set => SetValue(SourceUriProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public Stream SourceStream |
||||||
|
{ |
||||||
|
get => GetValue(SourceStreamProperty); |
||||||
|
set => SetValue(SourceStreamProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public IterationCount IterationCount |
||||||
|
{ |
||||||
|
get => GetValue(IterationCountProperty); |
||||||
|
set => SetValue(IterationCountProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public StretchDirection StretchDirection |
||||||
|
{ |
||||||
|
get => GetValue(StretchDirectionProperty); |
||||||
|
set => SetValue(StretchDirectionProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public Stretch Stretch |
||||||
|
{ |
||||||
|
get => GetValue(StretchProperty); |
||||||
|
set => SetValue(StretchProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
private static void IterationCountChanged(AvaloniaPropertyChangedEventArgs e) |
||||||
|
{ |
||||||
|
var image = e.Sender as GifImage; |
||||||
|
if (image is null || e.NewValue is not IterationCount iterationCount) |
||||||
|
return; |
||||||
|
|
||||||
|
image.IterationCount = iterationCount; |
||||||
|
} |
||||||
|
|
||||||
|
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) |
||||||
|
{ |
||||||
|
var compositor = ElementComposition.GetElementVisual(this)?.Compositor; |
||||||
|
if (compositor == null || _customVisual?.Compositor == compositor) |
||||||
|
return; |
||||||
|
_customVisual = compositor.CreateCustomVisual(new CustomVisualHandler()); |
||||||
|
ElementComposition.SetElementChildVisual(this, _customVisual); |
||||||
|
_customVisual.SendHandlerMessage(CustomVisualHandler.StartMessage); |
||||||
|
|
||||||
|
if (_initialSource is not null) |
||||||
|
{ |
||||||
|
UpdateGifInstance(_initialSource); |
||||||
|
_initialSource = null; |
||||||
|
} |
||||||
|
|
||||||
|
Update(); |
||||||
|
base.OnAttachedToVisualTree(e); |
||||||
|
} |
||||||
|
|
||||||
|
private void Update() |
||||||
|
{ |
||||||
|
if (_customVisual is null || _gifInstance is null) |
||||||
|
return; |
||||||
|
|
||||||
|
var dpi = this.GetVisualRoot()?.RenderScaling ?? 1.0; |
||||||
|
var sourceSize = _gifInstance.GifPixelSize.ToSize(dpi); |
||||||
|
var viewPort = new Rect(Bounds.Size); |
||||||
|
|
||||||
|
var scale = Stretch.CalculateScaling(Bounds.Size, sourceSize, StretchDirection); |
||||||
|
var scaledSize = sourceSize * scale; |
||||||
|
var destRect = viewPort.CenterRect(new Rect(scaledSize)).Intersect(viewPort); |
||||||
|
|
||||||
|
if (Stretch == Stretch.None) |
||||||
|
{ |
||||||
|
_customVisual.Size = new Vector2((float)sourceSize.Width, (float)sourceSize.Height); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
_customVisual.Size = new Vector2((float)destRect.Size.Width, (float)destRect.Size.Height); |
||||||
|
} |
||||||
|
|
||||||
|
_customVisual.Offset = new Vector3((float)destRect.Position.X, (float)destRect.Position.Y, 0); |
||||||
|
} |
||||||
|
|
||||||
|
private class CustomVisualHandler : CompositionCustomVisualHandler |
||||||
|
{ |
||||||
|
private TimeSpan _animationElapsed; |
||||||
|
private TimeSpan? _lastServerTime; |
||||||
|
private IGifInstance? _currentInstance; |
||||||
|
private bool _running; |
||||||
|
|
||||||
|
public static readonly object StopMessage = new(), |
||||||
|
StartMessage = new(); |
||||||
|
|
||||||
|
public override void OnMessage(object message) |
||||||
|
{ |
||||||
|
if (message == StartMessage) |
||||||
|
{ |
||||||
|
_running = true; |
||||||
|
_lastServerTime = null; |
||||||
|
RegisterForNextAnimationFrameUpdate(); |
||||||
|
} |
||||||
|
else if (message == StopMessage) |
||||||
|
{ |
||||||
|
_running = false; |
||||||
|
} |
||||||
|
else if (message is IGifInstance instance) |
||||||
|
{ |
||||||
|
_currentInstance?.Dispose(); |
||||||
|
_currentInstance = instance; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public override void OnAnimationFrameUpdate() |
||||||
|
{ |
||||||
|
if (!_running) |
||||||
|
return; |
||||||
|
Invalidate(); |
||||||
|
RegisterForNextAnimationFrameUpdate(); |
||||||
|
} |
||||||
|
|
||||||
|
public override void OnRender(ImmediateDrawingContext drawingContext) |
||||||
|
{ |
||||||
|
if (_running) |
||||||
|
{ |
||||||
|
if (_lastServerTime.HasValue) |
||||||
|
_animationElapsed += (CompositionNow - _lastServerTime.Value); |
||||||
|
_lastServerTime = CompositionNow; |
||||||
|
} |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
if (_currentInstance is null || _currentInstance.IsDisposed) |
||||||
|
return; |
||||||
|
|
||||||
|
var bitmap = _currentInstance.ProcessFrameTime(_animationElapsed); |
||||||
|
if (bitmap is not null) |
||||||
|
{ |
||||||
|
drawingContext.DrawBitmap( |
||||||
|
bitmap, |
||||||
|
new Rect(_currentInstance.GifPixelSize.ToSize(1)), |
||||||
|
GetRenderBounds() |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
Logger.Sink?.Log(LogEventLevel.Error, "GifImage Renderer ", this, e.ToString()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Measures the control. |
||||||
|
/// </summary> |
||||||
|
/// <param name="availableSize">The available size.</param> |
||||||
|
/// <returns>The desired size of the control.</returns> |
||||||
|
protected override Size MeasureOverride(Size availableSize) |
||||||
|
{ |
||||||
|
var result = new Size(); |
||||||
|
var scaling = this.GetVisualRoot()?.RenderScaling ?? 1.0; |
||||||
|
if (_gifInstance != null) |
||||||
|
{ |
||||||
|
result = Stretch.CalculateSize( |
||||||
|
availableSize, |
||||||
|
_gifInstance.GifPixelSize.ToSize(scaling), |
||||||
|
StretchDirection |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc/> |
||||||
|
protected override Size ArrangeOverride(Size finalSize) |
||||||
|
{ |
||||||
|
if (_gifInstance is null) |
||||||
|
return new Size(); |
||||||
|
var scaling = this.GetVisualRoot()?.RenderScaling ?? 1.0; |
||||||
|
var sourceSize = _gifInstance.GifPixelSize.ToSize(scaling); |
||||||
|
var result = Stretch.CalculateSize(finalSize, sourceSize); |
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
private void SourceChanged(AvaloniaPropertyChangedEventArgs e) |
||||||
|
{ |
||||||
|
if ( |
||||||
|
e.NewValue is null |
||||||
|
|| (e.NewValue is string value && !Uri.IsWellFormedUriString(value, UriKind.Absolute)) |
||||||
|
) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (_customVisual is null) |
||||||
|
{ |
||||||
|
_initialSource = e.NewValue; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
UpdateGifInstance(e.NewValue); |
||||||
|
|
||||||
|
InvalidateArrange(); |
||||||
|
InvalidateMeasure(); |
||||||
|
Update(); |
||||||
|
} |
||||||
|
|
||||||
|
private void UpdateGifInstance(object source) |
||||||
|
{ |
||||||
|
_gifInstance?.Dispose(); |
||||||
|
_gifInstance = new WebpInstance(source); |
||||||
|
// _gifInstance = new GifInstance(source); |
||||||
|
_gifInstance.IterationCount = IterationCount; |
||||||
|
_customVisual?.SendHandlerMessage(_gifInstance); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,147 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Animation; |
||||||
|
using Avalonia.Gif.Decoding; |
||||||
|
using Avalonia.Media.Imaging; |
||||||
|
using Avalonia.Platform; |
||||||
|
|
||||||
|
namespace Avalonia.Gif |
||||||
|
{ |
||||||
|
public class GifInstance : IGifInstance |
||||||
|
{ |
||||||
|
public IterationCount IterationCount { get; set; } |
||||||
|
public bool AutoStart { get; private set; } = true; |
||||||
|
private readonly GifDecoder _gifDecoder; |
||||||
|
private readonly WriteableBitmap? _targetBitmap; |
||||||
|
private TimeSpan _totalTime; |
||||||
|
private readonly List<TimeSpan> _frameTimes; |
||||||
|
private uint _iterationCount; |
||||||
|
private int _currentFrameIndex; |
||||||
|
private readonly List<ulong> _colorTableIdList; |
||||||
|
|
||||||
|
public CancellationTokenSource CurrentCts { get; } |
||||||
|
|
||||||
|
internal GifInstance(object newValue) |
||||||
|
: this( |
||||||
|
newValue switch |
||||||
|
{ |
||||||
|
Stream s => s, |
||||||
|
Uri u => GetStreamFromUri(u), |
||||||
|
string str => GetStreamFromString(str), |
||||||
|
_ => throw new InvalidDataException("Unsupported source object") |
||||||
|
} |
||||||
|
) { } |
||||||
|
|
||||||
|
public GifInstance(string uri) |
||||||
|
: this(GetStreamFromString(uri)) { } |
||||||
|
|
||||||
|
public GifInstance(Uri uri) |
||||||
|
: this(GetStreamFromUri(uri)) { } |
||||||
|
|
||||||
|
public GifInstance(Stream currentStream) |
||||||
|
{ |
||||||
|
if (!currentStream.CanSeek) |
||||||
|
throw new InvalidDataException("The provided stream is not seekable."); |
||||||
|
|
||||||
|
if (!currentStream.CanRead) |
||||||
|
throw new InvalidOperationException("Can't read the stream provided."); |
||||||
|
|
||||||
|
currentStream.Seek(0, SeekOrigin.Begin); |
||||||
|
|
||||||
|
CurrentCts = new CancellationTokenSource(); |
||||||
|
|
||||||
|
_gifDecoder = new GifDecoder(currentStream, CurrentCts.Token); |
||||||
|
var pixSize = new PixelSize(_gifDecoder.Header.Dimensions.Width, _gifDecoder.Header.Dimensions.Height); |
||||||
|
|
||||||
|
_targetBitmap = new WriteableBitmap(pixSize, new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque); |
||||||
|
GifPixelSize = pixSize; |
||||||
|
|
||||||
|
_totalTime = TimeSpan.Zero; |
||||||
|
|
||||||
|
_frameTimes = _gifDecoder |
||||||
|
.Frames |
||||||
|
.Select(frame => |
||||||
|
{ |
||||||
|
_totalTime = _totalTime.Add(frame.FrameDelay); |
||||||
|
return _totalTime; |
||||||
|
}) |
||||||
|
.ToList(); |
||||||
|
|
||||||
|
_gifDecoder.RenderFrame(0, _targetBitmap); |
||||||
|
} |
||||||
|
|
||||||
|
private static Stream GetStreamFromString(string str) |
||||||
|
{ |
||||||
|
if (!Uri.TryCreate(str, UriKind.RelativeOrAbsolute, out var res)) |
||||||
|
{ |
||||||
|
throw new InvalidCastException("The string provided can't be converted to URI."); |
||||||
|
} |
||||||
|
|
||||||
|
return GetStreamFromUri(res); |
||||||
|
} |
||||||
|
|
||||||
|
private static Stream GetStreamFromUri(Uri uri) |
||||||
|
{ |
||||||
|
var uriString = uri.OriginalString.Trim(); |
||||||
|
|
||||||
|
if (!uriString.StartsWith("resm") && !uriString.StartsWith("avares")) |
||||||
|
{ |
||||||
|
return new FileStream(uriString, FileMode.Open, FileAccess.Read); |
||||||
|
} |
||||||
|
|
||||||
|
return AssetLoader.Open(uri); |
||||||
|
} |
||||||
|
|
||||||
|
public int GifFrameCount => _frameTimes.Count; |
||||||
|
|
||||||
|
public PixelSize GifPixelSize { get; } |
||||||
|
|
||||||
|
public void Dispose() |
||||||
|
{ |
||||||
|
IsDisposed = true; |
||||||
|
CurrentCts.Cancel(); |
||||||
|
_targetBitmap?.Dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
public bool IsDisposed { get; private set; } |
||||||
|
|
||||||
|
public WriteableBitmap? ProcessFrameTime(TimeSpan stopwatchElapsed) |
||||||
|
{ |
||||||
|
if (!IterationCount.IsInfinite && _iterationCount > IterationCount.Value) |
||||||
|
{ |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
if (CurrentCts.IsCancellationRequested || _targetBitmap is null) |
||||||
|
{ |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
var elapsedTicks = stopwatchElapsed.Ticks; |
||||||
|
var timeModulus = TimeSpan.FromTicks(elapsedTicks % _totalTime.Ticks); |
||||||
|
var targetFrame = _frameTimes.FirstOrDefault(x => timeModulus < x); |
||||||
|
var currentFrame = _frameTimes.IndexOf(targetFrame); |
||||||
|
if (currentFrame == -1) |
||||||
|
currentFrame = 0; |
||||||
|
|
||||||
|
if (_currentFrameIndex == currentFrame) |
||||||
|
return _targetBitmap; |
||||||
|
|
||||||
|
_iterationCount = (uint)(elapsedTicks / _totalTime.Ticks); |
||||||
|
|
||||||
|
return ProcessFrameIndex(currentFrame); |
||||||
|
} |
||||||
|
|
||||||
|
internal WriteableBitmap ProcessFrameIndex(int frameIndex) |
||||||
|
{ |
||||||
|
_gifDecoder.RenderFrame(frameIndex, _targetBitmap); |
||||||
|
_currentFrameIndex = frameIndex; |
||||||
|
|
||||||
|
return _targetBitmap; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,15 @@ |
|||||||
|
using Avalonia.Animation; |
||||||
|
using Avalonia.Media.Imaging; |
||||||
|
|
||||||
|
namespace Avalonia.Gif; |
||||||
|
|
||||||
|
public interface IGifInstance : IDisposable |
||||||
|
{ |
||||||
|
IterationCount IterationCount { get; set; } |
||||||
|
bool AutoStart { get; } |
||||||
|
CancellationTokenSource CurrentCts { get; } |
||||||
|
int GifFrameCount { get; } |
||||||
|
PixelSize GifPixelSize { get; } |
||||||
|
bool IsDisposed { get; } |
||||||
|
WriteableBitmap? ProcessFrameTime(TimeSpan stopwatchElapsed); |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
using System; |
||||||
|
using System.Runtime.Serialization; |
||||||
|
|
||||||
|
namespace Avalonia.Gif |
||||||
|
{ |
||||||
|
[Serializable] |
||||||
|
internal class InvalidGifStreamException : Exception |
||||||
|
{ |
||||||
|
public InvalidGifStreamException() { } |
||||||
|
|
||||||
|
public InvalidGifStreamException(string message) |
||||||
|
: base(message) { } |
||||||
|
|
||||||
|
public InvalidGifStreamException(string message, Exception innerException) |
||||||
|
: base(message, innerException) { } |
||||||
|
|
||||||
|
protected InvalidGifStreamException(SerializationInfo info, StreamingContext context) |
||||||
|
: base(info, context) { } |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,180 @@ |
|||||||
|
using Avalonia.Animation; |
||||||
|
using Avalonia.Media.Imaging; |
||||||
|
using Avalonia.Platform; |
||||||
|
using SkiaSharp; |
||||||
|
|
||||||
|
namespace Avalonia.Gif; |
||||||
|
|
||||||
|
public class WebpInstance : IGifInstance |
||||||
|
{ |
||||||
|
public IterationCount IterationCount { get; set; } |
||||||
|
public bool AutoStart { get; private set; } = true; |
||||||
|
|
||||||
|
private readonly WriteableBitmap? _targetBitmap; |
||||||
|
private TimeSpan _totalTime; |
||||||
|
private readonly List<TimeSpan> _frameTimes; |
||||||
|
private uint _iterationCount; |
||||||
|
private int _currentFrameIndex; |
||||||
|
|
||||||
|
private SKCodec? _codec; |
||||||
|
|
||||||
|
public CancellationTokenSource CurrentCts { get; } |
||||||
|
|
||||||
|
internal WebpInstance(object newValue) |
||||||
|
: this( |
||||||
|
newValue switch |
||||||
|
{ |
||||||
|
Stream s => s, |
||||||
|
Uri u => GetStreamFromUri(u), |
||||||
|
string str => GetStreamFromString(str), |
||||||
|
_ => throw new InvalidDataException("Unsupported source object") |
||||||
|
} |
||||||
|
) { } |
||||||
|
|
||||||
|
public WebpInstance(string uri) |
||||||
|
: this(GetStreamFromString(uri)) { } |
||||||
|
|
||||||
|
public WebpInstance(Uri uri) |
||||||
|
: this(GetStreamFromUri(uri)) { } |
||||||
|
|
||||||
|
public WebpInstance(Stream currentStream) |
||||||
|
{ |
||||||
|
if (!currentStream.CanSeek) |
||||||
|
throw new InvalidDataException("The provided stream is not seekable."); |
||||||
|
|
||||||
|
if (!currentStream.CanRead) |
||||||
|
throw new InvalidOperationException("Can't read the stream provided."); |
||||||
|
|
||||||
|
currentStream.Seek(0, SeekOrigin.Begin); |
||||||
|
|
||||||
|
CurrentCts = new CancellationTokenSource(); |
||||||
|
|
||||||
|
var managedStream = new SKManagedStream(currentStream); |
||||||
|
_codec = SKCodec.Create(managedStream); |
||||||
|
|
||||||
|
var pixSize = new PixelSize(_codec.Info.Width, _codec.Info.Height); |
||||||
|
|
||||||
|
_targetBitmap = new WriteableBitmap(pixSize, new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque); |
||||||
|
GifPixelSize = pixSize; |
||||||
|
|
||||||
|
_totalTime = TimeSpan.Zero; |
||||||
|
|
||||||
|
_frameTimes = _codec |
||||||
|
.FrameInfo |
||||||
|
.Select(frame => |
||||||
|
{ |
||||||
|
_totalTime = _totalTime.Add(TimeSpan.FromMilliseconds(frame.Duration)); |
||||||
|
return _totalTime; |
||||||
|
}) |
||||||
|
.ToList(); |
||||||
|
|
||||||
|
RenderFrame(_codec, _targetBitmap, 0); |
||||||
|
} |
||||||
|
|
||||||
|
private static void RenderFrame(SKCodec codec, WriteableBitmap targetBitmap, int index) |
||||||
|
{ |
||||||
|
codec.GetFrameInfo(index, out var frameInfo); |
||||||
|
|
||||||
|
var info = new SKImageInfo(codec.Info.Width, codec.Info.Height); |
||||||
|
var decodeInfo = info.WithAlphaType(frameInfo.AlphaType); |
||||||
|
|
||||||
|
using var frameBuffer = targetBitmap.Lock(); |
||||||
|
|
||||||
|
var result = codec.GetPixels(decodeInfo, frameBuffer.Address, new SKCodecOptions(index)); |
||||||
|
|
||||||
|
if (result != SKCodecResult.Success) |
||||||
|
throw new InvalidDataException($"Could not decode frame {index} of {codec.FrameCount}."); |
||||||
|
} |
||||||
|
|
||||||
|
private static void RenderFrame(SKCodec codec, WriteableBitmap targetBitmap, int index, int priorIndex) |
||||||
|
{ |
||||||
|
codec.GetFrameInfo(index, out var frameInfo); |
||||||
|
|
||||||
|
var info = new SKImageInfo(codec.Info.Width, codec.Info.Height); |
||||||
|
var decodeInfo = info.WithAlphaType(frameInfo.AlphaType); |
||||||
|
|
||||||
|
using var frameBuffer = targetBitmap.Lock(); |
||||||
|
|
||||||
|
var result = codec.GetPixels(decodeInfo, frameBuffer.Address, new SKCodecOptions(index, priorIndex)); |
||||||
|
|
||||||
|
if (result != SKCodecResult.Success) |
||||||
|
throw new InvalidDataException($"Could not decode frame {index} of {codec.FrameCount}."); |
||||||
|
} |
||||||
|
|
||||||
|
private static Stream GetStreamFromString(string str) |
||||||
|
{ |
||||||
|
if (!Uri.TryCreate(str, UriKind.RelativeOrAbsolute, out var res)) |
||||||
|
{ |
||||||
|
throw new InvalidCastException("The string provided can't be converted to URI."); |
||||||
|
} |
||||||
|
|
||||||
|
return GetStreamFromUri(res); |
||||||
|
} |
||||||
|
|
||||||
|
private static Stream GetStreamFromUri(Uri uri) |
||||||
|
{ |
||||||
|
var uriString = uri.OriginalString.Trim(); |
||||||
|
|
||||||
|
if (!uriString.StartsWith("resm") && !uriString.StartsWith("avares")) |
||||||
|
{ |
||||||
|
return new FileStream(uriString, FileMode.Open, FileAccess.Read); |
||||||
|
} |
||||||
|
|
||||||
|
return AssetLoader.Open(uri); |
||||||
|
} |
||||||
|
|
||||||
|
public int GifFrameCount => _frameTimes.Count; |
||||||
|
|
||||||
|
public PixelSize GifPixelSize { get; } |
||||||
|
|
||||||
|
public void Dispose() |
||||||
|
{ |
||||||
|
IsDisposed = true; |
||||||
|
CurrentCts.Cancel(); |
||||||
|
_targetBitmap?.Dispose(); |
||||||
|
_codec?.Dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
public bool IsDisposed { get; private set; } |
||||||
|
|
||||||
|
public WriteableBitmap? ProcessFrameTime(TimeSpan stopwatchElapsed) |
||||||
|
{ |
||||||
|
if (!IterationCount.IsInfinite && _iterationCount > IterationCount.Value) |
||||||
|
{ |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
if (CurrentCts.IsCancellationRequested || _targetBitmap is null) |
||||||
|
{ |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
var elapsedTicks = stopwatchElapsed.Ticks; |
||||||
|
var timeModulus = TimeSpan.FromTicks(elapsedTicks % _totalTime.Ticks); |
||||||
|
var targetFrame = _frameTimes.FirstOrDefault(x => timeModulus < x); |
||||||
|
var currentFrame = _frameTimes.IndexOf(targetFrame); |
||||||
|
if (currentFrame == -1) |
||||||
|
currentFrame = 0; |
||||||
|
|
||||||
|
if (_currentFrameIndex == currentFrame) |
||||||
|
return _targetBitmap; |
||||||
|
|
||||||
|
_iterationCount = (uint)(elapsedTicks / _totalTime.Ticks); |
||||||
|
|
||||||
|
return ProcessFrameIndex(currentFrame); |
||||||
|
} |
||||||
|
|
||||||
|
internal WriteableBitmap ProcessFrameIndex(int frameIndex) |
||||||
|
{ |
||||||
|
if (_codec is null) |
||||||
|
throw new InvalidOperationException("The codec is null."); |
||||||
|
|
||||||
|
if (_targetBitmap is null) |
||||||
|
throw new InvalidOperationException("The target bitmap is null."); |
||||||
|
|
||||||
|
RenderFrame(_codec, _targetBitmap, frameIndex, _currentFrameIndex); |
||||||
|
_currentFrameIndex = frameIndex; |
||||||
|
|
||||||
|
return _targetBitmap; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,8 @@ |
|||||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||||
|
<plist version="1.0"> |
||||||
|
<dict> |
||||||
|
<key>com.apple.security.cs.allow-jit</key> |
||||||
|
<true/> |
||||||
|
</dict> |
||||||
|
</plist> |
@ -0,0 +1,12 @@ |
|||||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||||
|
<plist version="1.0"> |
||||||
|
<dict> |
||||||
|
<key>com.apple.security.cs.allow-jit</key> |
||||||
|
<true/> |
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key> |
||||||
|
<true/> |
||||||
|
<key>com.apple.security.cs.disable-library-validation</key> |
||||||
|
<true/> |
||||||
|
</dict> |
||||||
|
</plist> |
@ -0,0 +1,26 @@ |
|||||||
|
#!/bin/sh |
||||||
|
|
||||||
|
while getopts v: flag |
||||||
|
do |
||||||
|
case "${flag}" in |
||||||
|
v) version=${OPTARG};; |
||||||
|
*) echo "Invalid option";; |
||||||
|
esac |
||||||
|
done |
||||||
|
|
||||||
|
dotnet \ |
||||||
|
msbuild \ |
||||||
|
StabilityMatrix.Avalonia \ |
||||||
|
-t:BundleApp \ |
||||||
|
-p:RuntimeIdentifier=osx-arm64 \ |
||||||
|
-p:UseAppHost=true \ |
||||||
|
-p:Configuration=Release \ |
||||||
|
-p:CFBundleShortVersionString="$version" \ |
||||||
|
-p:SelfContained=true \ |
||||||
|
-p:CFBundleName="Stability Matrix" \ |
||||||
|
-p:CFBundleDisplayName="Stability Matrix" \ |
||||||
|
-p:CFBundleVersion="$version" \ |
||||||
|
-p:PublishDir="$(pwd)/out/osx-arm64/bin" \ |
||||||
|
|
||||||
|
# Copy the app out of bin |
||||||
|
cp -r ./out/osx-arm64/bin/Stability\ Matrix.app ./out/osx-arm64/Stability\ Matrix.app |
@ -0,0 +1,62 @@ |
|||||||
|
#!/bin/sh |
||||||
|
|
||||||
|
echo "Signing file: $1" |
||||||
|
|
||||||
|
# Setup keychain in CI |
||||||
|
if [ -n "$CI" ]; then |
||||||
|
# Turn our base64-encoded certificate back to a regular .p12 file |
||||||
|
|
||||||
|
echo "$MACOS_CERTIFICATE" | base64 --decode -o certificate.p12 |
||||||
|
|
||||||
|
# We need to create a new keychain, otherwise using the certificate will prompt |
||||||
|
# with a UI dialog asking for the certificate password, which we can't |
||||||
|
# use in a headless CI environment |
||||||
|
|
||||||
|
security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain |
||||||
|
security default-keychain -s build.keychain |
||||||
|
security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain |
||||||
|
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign |
||||||
|
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain |
||||||
|
fi |
||||||
|
|
||||||
|
# Sign all files |
||||||
|
PARENT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" || return ; pwd -P ) |
||||||
|
ENTITLEMENTS="$PARENT_PATH/EmbeddedEntitlements.entitlements" |
||||||
|
|
||||||
|
echo "Using entitlements file: $ENTITLEMENTS" |
||||||
|
|
||||||
|
# App |
||||||
|
if [ "$1" == "*.app" ]; then |
||||||
|
echo "[INFO] Signing app contents" |
||||||
|
|
||||||
|
find "$1/Contents/MacOS/"|while read fname; do |
||||||
|
if [[ -f $fname ]]; then |
||||||
|
echo "[INFO] Signing $fname" |
||||||
|
codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$fname" |
||||||
|
fi |
||||||
|
done |
||||||
|
|
||||||
|
echo "[INFO] Signing app file" |
||||||
|
|
||||||
|
codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$1" -v |
||||||
|
# Directory |
||||||
|
elif [ -d "$1" ]; then |
||||||
|
echo "[INFO] Signing directory contents" |
||||||
|
|
||||||
|
find "$1"|while read fname; do |
||||||
|
if [[ -f $fname ]] && [[ ! $fname =~ /(*.(py|msg|enc))/ ]]; then |
||||||
|
echo "[INFO] Signing $fname" |
||||||
|
|
||||||
|
codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$fname" |
||||||
|
fi |
||||||
|
done |
||||||
|
# File |
||||||
|
elif [ -f "$1" ]; then |
||||||
|
echo "[INFO] Signing file" |
||||||
|
|
||||||
|
codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$1" -v |
||||||
|
# Not matched |
||||||
|
else |
||||||
|
echo "[ERROR] Unknown file type" |
||||||
|
exit 1 |
||||||
|
fi |
@ -0,0 +1,37 @@ |
|||||||
|
#!/bin/sh |
||||||
|
|
||||||
|
echo "Signing file: $1" |
||||||
|
|
||||||
|
# Setup keychain in CI |
||||||
|
if [ -n "$CI" ]; then |
||||||
|
# Turn our base64-encoded certificate back to a regular .p12 file |
||||||
|
|
||||||
|
echo "$MACOS_CERTIFICATE" | base64 --decode -o certificate.p12 |
||||||
|
|
||||||
|
# We need to create a new keychain, otherwise using the certificate will prompt |
||||||
|
# with a UI dialog asking for the certificate password, which we can't |
||||||
|
# use in a headless CI environment |
||||||
|
|
||||||
|
security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain |
||||||
|
security default-keychain -s build.keychain |
||||||
|
security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain |
||||||
|
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign |
||||||
|
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain |
||||||
|
fi |
||||||
|
|
||||||
|
# Sign all files |
||||||
|
PARENT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" || return ; pwd -P ) |
||||||
|
ENTITLEMENTS="$PARENT_PATH/AppEntitlements.entitlements" |
||||||
|
|
||||||
|
echo "Using entitlements file: $ENTITLEMENTS" |
||||||
|
|
||||||
|
find "$1/Contents/MacOS/"|while read fname; do |
||||||
|
if [[ -f $fname ]]; then |
||||||
|
echo "[INFO] Signing $fname" |
||||||
|
codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$fname" |
||||||
|
fi |
||||||
|
done |
||||||
|
|
||||||
|
echo "[INFO] Signing app file" |
||||||
|
|
||||||
|
codesign --force --timestamp -s "$MACOS_CERTIFICATE_NAME" --options=runtime --entitlements "$ENTITLEMENTS" "$1" -v |
@ -0,0 +1,32 @@ |
|||||||
|
#!/bin/sh |
||||||
|
|
||||||
|
echo "Notarizing file: $1" |
||||||
|
|
||||||
|
# Store the notarization credentials so that we can prevent a UI password dialog |
||||||
|
# from blocking the CI |
||||||
|
|
||||||
|
echo "Create keychain profile" |
||||||
|
xcrun notarytool store-credentials "notarytool-profile" \ |
||||||
|
--apple-id "$MACOS_NOTARIZATION_APPLE_ID" \ |
||||||
|
--team-id "$MACOS_NOTARIZATION_TEAM_ID" \ |
||||||
|
--password "$MACOS_NOTARIZATION_PWD" |
||||||
|
|
||||||
|
# We can't notarize an app bundle directly, but we need to compress it as an archive. |
||||||
|
# Therefore, we create a zip file containing our app bundle, so that we can send it to the |
||||||
|
# notarization service |
||||||
|
|
||||||
|
echo "Creating temp notarization archive" |
||||||
|
ditto -c -k --keepParent "$1" "notarization.zip" |
||||||
|
|
||||||
|
# Here we send the notarization request to the Apple's Notarization service, waiting for the result. |
||||||
|
# This typically takes a few seconds inside a CI environment, but it might take more depending on the App |
||||||
|
# characteristics. Visit the Notarization docs for more information and strategies on how to optimize it if |
||||||
|
# you're curious |
||||||
|
|
||||||
|
echo "Notarize app" |
||||||
|
xcrun notarytool submit "notarization.zip" --keychain-profile "notarytool-profile" --wait |
||||||
|
|
||||||
|
# Finally, we need to "attach the staple" to our executable, which will allow our app to be |
||||||
|
# validated by macOS even when an internet connection is not available. |
||||||
|
echo "Attach staple" |
||||||
|
xcrun stapler staple "$1" |
@ -0,0 +1,5 @@ |
|||||||
|
<Project> |
||||||
|
<PropertyGroup> |
||||||
|
<AvaloniaVersion>11.0.9</AvaloniaVersion> |
||||||
|
</PropertyGroup> |
||||||
|
</Project> |
Binary file not shown.
@ -0,0 +1,141 @@ |
|||||||
|
using System; |
||||||
|
using System.Linq; |
||||||
|
using System.Reactive; |
||||||
|
using System.Reactive.Disposables; |
||||||
|
using System.Reactive.Linq; |
||||||
|
using DynamicData; |
||||||
|
using DynamicData.Binding; |
||||||
|
using JetBrains.Annotations; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Collections; |
||||||
|
|
||||||
|
[PublicAPI] |
||||||
|
public class SearchCollection<TObject, TKey, TQuery> : AbstractNotifyPropertyChanged, IDisposable |
||||||
|
where TObject : notnull |
||||||
|
where TKey : notnull |
||||||
|
{ |
||||||
|
private readonly IDisposable cleanUp; |
||||||
|
|
||||||
|
private Func<TQuery?, Func<TObject, bool>>? PredicateSelector { get; } |
||||||
|
private Func<TQuery?, Func<TObject, (bool, int)>>? ScorerSelector { get; } |
||||||
|
private Func<TObject, (bool, int)>? Scorer { get; set; } |
||||||
|
|
||||||
|
private TQuery? _query; |
||||||
|
public TQuery? Query |
||||||
|
{ |
||||||
|
get => _query; |
||||||
|
set => SetAndRaise(ref _query, value); |
||||||
|
} |
||||||
|
|
||||||
|
private SortExpressionComparer<TObject> _sortComparer = []; |
||||||
|
public SortExpressionComparer<TObject> SortComparer |
||||||
|
{ |
||||||
|
get => _sortComparer; |
||||||
|
set => SetAndRaise(ref _sortComparer, value); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Converts <see cref="SortComparer"/> to <see cref="SortExpressionComparer{SearchItem}"/>. |
||||||
|
/// </summary> |
||||||
|
private SortExpressionComparer<SearchItem> SearchItemSortComparer => |
||||||
|
[ |
||||||
|
..SortComparer |
||||||
|
.Select(sortExpression => new SortExpression<SearchItem>( |
||||||
|
item => sortExpression.Expression(item.Item), |
||||||
|
sortExpression.Direction |
||||||
|
)).Prepend(new SortExpression<SearchItem>(item => item.Score, SortDirection.Descending)) |
||||||
|
]; |
||||||
|
|
||||||
|
public IObservableCollection<TObject> Items { get; } = new ObservableCollectionExtended<TObject>(); |
||||||
|
|
||||||
|
public IObservableCollection<TObject> FilteredItems { get; } = |
||||||
|
new ObservableCollectionExtended<TObject>(); |
||||||
|
|
||||||
|
public SearchCollection( |
||||||
|
IObservable<IChangeSet<TObject, TKey>> source, |
||||||
|
Func<TQuery?, Func<TObject, bool>> predicateSelector, |
||||||
|
SortExpressionComparer<TObject>? sortComparer = null |
||||||
|
) |
||||||
|
{ |
||||||
|
PredicateSelector = predicateSelector; |
||||||
|
|
||||||
|
if (sortComparer is not null) |
||||||
|
{ |
||||||
|
SortComparer = sortComparer; |
||||||
|
} |
||||||
|
|
||||||
|
// Observable which creates a new predicate whenever Query property changes |
||||||
|
var dynamicPredicate = this.WhenValueChanged(@this => @this.Query).Select(predicateSelector); |
||||||
|
|
||||||
|
cleanUp = source |
||||||
|
.Bind(Items) |
||||||
|
.Filter(dynamicPredicate) |
||||||
|
.Sort(SortComparer) |
||||||
|
.Bind(FilteredItems) |
||||||
|
.Subscribe(); |
||||||
|
} |
||||||
|
|
||||||
|
public SearchCollection( |
||||||
|
IObservable<IChangeSet<TObject, TKey>> source, |
||||||
|
Func<TQuery?, Func<TObject, (bool, int)>> scorerSelector, |
||||||
|
SortExpressionComparer<TObject>? sortComparer = null |
||||||
|
) |
||||||
|
{ |
||||||
|
ScorerSelector = scorerSelector; |
||||||
|
|
||||||
|
if (sortComparer is not null) |
||||||
|
{ |
||||||
|
SortComparer = sortComparer; |
||||||
|
} |
||||||
|
|
||||||
|
// Monitor Query property for changes |
||||||
|
var queryChanged = this.WhenValueChanged(@this => @this.Query).Select(_ => Unit.Default); |
||||||
|
|
||||||
|
cleanUp = new CompositeDisposable( |
||||||
|
// Update Scorer property whenever Query property changes |
||||||
|
queryChanged.Subscribe(_ => Scorer = scorerSelector(Query)), |
||||||
|
// Transform source items into SearchItems |
||||||
|
source |
||||||
|
.Transform( |
||||||
|
obj => |
||||||
|
{ |
||||||
|
var (isMatch, score) = Scorer?.Invoke(obj) ?? (true, 0); |
||||||
|
|
||||||
|
return new SearchItem |
||||||
|
{ |
||||||
|
Item = obj, |
||||||
|
IsMatch = isMatch, |
||||||
|
Score = score |
||||||
|
}; |
||||||
|
}, |
||||||
|
forceTransform: queryChanged |
||||||
|
) |
||||||
|
.Filter(item => item.IsMatch) |
||||||
|
.Sort(SearchItemSortComparer, SortOptimisations.ComparesImmutableValuesOnly) |
||||||
|
.Transform(searchItem => searchItem.Item) |
||||||
|
.Bind(FilteredItems) |
||||||
|
.Subscribe() |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Clears <see cref="Query"/> property by setting it to default value. |
||||||
|
/// </summary> |
||||||
|
public void ClearQuery() |
||||||
|
{ |
||||||
|
Query = default; |
||||||
|
} |
||||||
|
|
||||||
|
public void Dispose() |
||||||
|
{ |
||||||
|
cleanUp.Dispose(); |
||||||
|
GC.SuppressFinalize(this); |
||||||
|
} |
||||||
|
|
||||||
|
private readonly record struct SearchItem |
||||||
|
{ |
||||||
|
public TObject Item { get; init; } |
||||||
|
public int Score { get; init; } |
||||||
|
public bool IsMatch { get; init; } |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,38 @@ |
|||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using Avalonia.Controls.Templates; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
public class BetterComboBox : ComboBox |
||||||
|
{ |
||||||
|
public static readonly DirectProperty<BetterComboBox, IDataTemplate?> SelectionBoxItemTemplateProperty = |
||||||
|
AvaloniaProperty.RegisterDirect<BetterComboBox, IDataTemplate?>( |
||||||
|
nameof(SelectionBoxItemTemplate), |
||||||
|
v => v.SelectionBoxItemTemplate, |
||||||
|
(x, v) => x.SelectionBoxItemTemplate = v |
||||||
|
); |
||||||
|
|
||||||
|
private IDataTemplate? _selectionBoxItemTemplate; |
||||||
|
|
||||||
|
public IDataTemplate? SelectionBoxItemTemplate |
||||||
|
{ |
||||||
|
get => _selectionBoxItemTemplate; |
||||||
|
set => SetAndRaise(SelectionBoxItemTemplateProperty, ref _selectionBoxItemTemplate, value); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e) |
||||||
|
{ |
||||||
|
base.OnApplyTemplate(e); |
||||||
|
|
||||||
|
if (e.NameScope.Find<ContentControl>("ContentPresenter") is { } contentPresenter) |
||||||
|
{ |
||||||
|
if (SelectionBoxItemTemplate is { } template) |
||||||
|
{ |
||||||
|
contentPresenter.ContentTemplate = template; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,208 @@ |
|||||||
|
using System; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Input; |
||||||
|
using Avalonia.Interactivity; |
||||||
|
using Avalonia.Xaml.Interactions.DragAndDrop; |
||||||
|
using Avalonia.Xaml.Interactivity; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
public class BetterContextDragBehavior : Behavior<Control> |
||||||
|
{ |
||||||
|
private Point _dragStartPoint; |
||||||
|
private PointerEventArgs? _triggerEvent; |
||||||
|
private bool _lock; |
||||||
|
private bool _captured; |
||||||
|
|
||||||
|
public static readonly StyledProperty<object?> ContextProperty = AvaloniaProperty.Register< |
||||||
|
ContextDragBehavior, |
||||||
|
object? |
||||||
|
>(nameof(Context)); |
||||||
|
|
||||||
|
public static readonly StyledProperty<IDragHandler?> HandlerProperty = AvaloniaProperty.Register< |
||||||
|
ContextDragBehavior, |
||||||
|
IDragHandler? |
||||||
|
>(nameof(Handler)); |
||||||
|
|
||||||
|
public static readonly StyledProperty<double> HorizontalDragThresholdProperty = AvaloniaProperty.Register< |
||||||
|
ContextDragBehavior, |
||||||
|
double |
||||||
|
>(nameof(HorizontalDragThreshold), 3); |
||||||
|
|
||||||
|
public static readonly StyledProperty<double> VerticalDragThresholdProperty = AvaloniaProperty.Register< |
||||||
|
ContextDragBehavior, |
||||||
|
double |
||||||
|
>(nameof(VerticalDragThreshold), 3); |
||||||
|
|
||||||
|
public static readonly StyledProperty<string> DataFormatProperty = AvaloniaProperty.Register< |
||||||
|
BetterContextDragBehavior, |
||||||
|
string |
||||||
|
>("DataFormat"); |
||||||
|
|
||||||
|
public string DataFormat |
||||||
|
{ |
||||||
|
get => GetValue(DataFormatProperty); |
||||||
|
set => SetValue(DataFormatProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public object? Context |
||||||
|
{ |
||||||
|
get => GetValue(ContextProperty); |
||||||
|
set => SetValue(ContextProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public IDragHandler? Handler |
||||||
|
{ |
||||||
|
get => GetValue(HandlerProperty); |
||||||
|
set => SetValue(HandlerProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public double HorizontalDragThreshold |
||||||
|
{ |
||||||
|
get => GetValue(HorizontalDragThresholdProperty); |
||||||
|
set => SetValue(HorizontalDragThresholdProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public double VerticalDragThreshold |
||||||
|
{ |
||||||
|
get => GetValue(VerticalDragThresholdProperty); |
||||||
|
set => SetValue(VerticalDragThresholdProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
protected override void OnAttachedToVisualTree() |
||||||
|
{ |
||||||
|
AssociatedObject?.AddHandler( |
||||||
|
InputElement.PointerPressedEvent, |
||||||
|
AssociatedObject_PointerPressed, |
||||||
|
RoutingStrategies.Direct | RoutingStrategies.Tunnel | RoutingStrategies.Bubble |
||||||
|
); |
||||||
|
AssociatedObject?.AddHandler( |
||||||
|
InputElement.PointerReleasedEvent, |
||||||
|
AssociatedObject_PointerReleased, |
||||||
|
RoutingStrategies.Direct | RoutingStrategies.Tunnel | RoutingStrategies.Bubble |
||||||
|
); |
||||||
|
AssociatedObject?.AddHandler( |
||||||
|
InputElement.PointerMovedEvent, |
||||||
|
AssociatedObject_PointerMoved, |
||||||
|
RoutingStrategies.Direct | RoutingStrategies.Tunnel | RoutingStrategies.Bubble |
||||||
|
); |
||||||
|
AssociatedObject?.AddHandler( |
||||||
|
InputElement.PointerCaptureLostEvent, |
||||||
|
AssociatedObject_CaptureLost, |
||||||
|
RoutingStrategies.Direct | RoutingStrategies.Tunnel | RoutingStrategies.Bubble |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
protected override void OnDetachedFromVisualTree() |
||||||
|
{ |
||||||
|
AssociatedObject?.RemoveHandler(InputElement.PointerPressedEvent, AssociatedObject_PointerPressed); |
||||||
|
AssociatedObject?.RemoveHandler(InputElement.PointerReleasedEvent, AssociatedObject_PointerReleased); |
||||||
|
AssociatedObject?.RemoveHandler(InputElement.PointerMovedEvent, AssociatedObject_PointerMoved); |
||||||
|
AssociatedObject?.RemoveHandler(InputElement.PointerCaptureLostEvent, AssociatedObject_CaptureLost); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task DoDragDrop(PointerEventArgs triggerEvent, object? value) |
||||||
|
{ |
||||||
|
var data = new DataObject(); |
||||||
|
data.Set(DataFormat, value!); |
||||||
|
|
||||||
|
var effect = DragDropEffects.None; |
||||||
|
|
||||||
|
if (triggerEvent.KeyModifiers.HasFlag(KeyModifiers.Alt)) |
||||||
|
{ |
||||||
|
effect |= DragDropEffects.Link; |
||||||
|
} |
||||||
|
else if (triggerEvent.KeyModifiers.HasFlag(KeyModifiers.Shift)) |
||||||
|
{ |
||||||
|
effect |= DragDropEffects.Move; |
||||||
|
} |
||||||
|
else if (triggerEvent.KeyModifiers.HasFlag(KeyModifiers.Control)) |
||||||
|
{ |
||||||
|
effect |= DragDropEffects.Copy; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
effect |= DragDropEffects.Move; |
||||||
|
} |
||||||
|
|
||||||
|
await DragDrop.DoDragDrop(triggerEvent, data, effect); |
||||||
|
} |
||||||
|
|
||||||
|
private void Released() |
||||||
|
{ |
||||||
|
_triggerEvent = null; |
||||||
|
_lock = false; |
||||||
|
} |
||||||
|
|
||||||
|
private void AssociatedObject_PointerPressed(object? sender, PointerPressedEventArgs e) |
||||||
|
{ |
||||||
|
var properties = e.GetCurrentPoint(AssociatedObject).Properties; |
||||||
|
if (properties.IsLeftButtonPressed) |
||||||
|
{ |
||||||
|
if (e.Source is Control control && AssociatedObject?.DataContext == control.DataContext) |
||||||
|
{ |
||||||
|
_dragStartPoint = e.GetPosition(null); |
||||||
|
_triggerEvent = e; |
||||||
|
_lock = true; |
||||||
|
_captured = true; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void AssociatedObject_PointerReleased(object? sender, PointerReleasedEventArgs e) |
||||||
|
{ |
||||||
|
if (_captured) |
||||||
|
{ |
||||||
|
if (e.InitialPressMouseButton == MouseButton.Left && _triggerEvent is { }) |
||||||
|
{ |
||||||
|
Released(); |
||||||
|
} |
||||||
|
|
||||||
|
_captured = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private async void AssociatedObject_PointerMoved(object? sender, PointerEventArgs e) |
||||||
|
{ |
||||||
|
var properties = e.GetCurrentPoint(AssociatedObject).Properties; |
||||||
|
if (_captured && properties.IsLeftButtonPressed && _triggerEvent is { }) |
||||||
|
{ |
||||||
|
var point = e.GetPosition(null); |
||||||
|
var diff = _dragStartPoint - point; |
||||||
|
var horizontalDragThreshold = HorizontalDragThreshold; |
||||||
|
var verticalDragThreshold = VerticalDragThreshold; |
||||||
|
|
||||||
|
if (Math.Abs(diff.X) > horizontalDragThreshold || Math.Abs(diff.Y) > verticalDragThreshold) |
||||||
|
{ |
||||||
|
if (_lock) |
||||||
|
{ |
||||||
|
_lock = false; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var context = Context ?? AssociatedObject?.DataContext; |
||||||
|
|
||||||
|
Handler?.BeforeDragDrop(sender, _triggerEvent, context); |
||||||
|
|
||||||
|
await DoDragDrop(_triggerEvent, context); |
||||||
|
|
||||||
|
Handler?.AfterDragDrop(sender, _triggerEvent, context); |
||||||
|
|
||||||
|
_triggerEvent = null; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void AssociatedObject_CaptureLost(object? sender, PointerCaptureLostEventArgs e) |
||||||
|
{ |
||||||
|
Released(); |
||||||
|
_captured = false; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,46 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Templates; |
||||||
|
using Avalonia.Metadata; |
||||||
|
using JetBrains.Annotations; |
||||||
|
using StabilityMatrix.Avalonia.Models; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Selector for objects implementing <see cref="ITemplateKey{T}"/> |
||||||
|
/// </summary> |
||||||
|
[PublicAPI] |
||||||
|
public class DataTemplateSelector<TKey> : IDataTemplate |
||||||
|
where TKey : notnull |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Key that is used when no other key matches |
||||||
|
/// </summary> |
||||||
|
public TKey? DefaultKey { get; set; } |
||||||
|
|
||||||
|
[Content] |
||||||
|
public Dictionary<TKey, IDataTemplate> Templates { get; } = new(); |
||||||
|
|
||||||
|
public bool Match(object? data) => data is ITemplateKey<TKey>; |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public Control Build(object? data) |
||||||
|
{ |
||||||
|
if (data is not ITemplateKey<TKey> key) |
||||||
|
throw new ArgumentException(null, nameof(data)); |
||||||
|
|
||||||
|
if (Templates.TryGetValue(key.TemplateKey, out var template)) |
||||||
|
{ |
||||||
|
return template.Build(data)!; |
||||||
|
} |
||||||
|
|
||||||
|
if (DefaultKey is not null && Templates.TryGetValue(DefaultKey, out var defaultTemplate)) |
||||||
|
{ |
||||||
|
return defaultTemplate.Build(data)!; |
||||||
|
} |
||||||
|
|
||||||
|
throw new ArgumentException(null, nameof(data)); |
||||||
|
} |
||||||
|
} |
@ -1,13 +1,122 @@ |
|||||||
using System; |
using System; |
||||||
|
using System.Diagnostics; |
||||||
|
using System.IO; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Presenters; |
||||||
|
using Avalonia.Logging; |
||||||
using FluentAvalonia.UI.Controls; |
using FluentAvalonia.UI.Controls; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
using Symbol = FluentIcons.Common.Symbol; |
||||||
|
|
||||||
namespace StabilityMatrix.Avalonia.Controls; |
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
/// <summary> |
/// <summary> |
||||||
/// Like <see cref="HyperlinkButton"/>, but with a link icon left of the text content. |
/// Like <see cref="HyperlinkButton"/>, but with a link icon left of the text content. |
||||||
/// </summary> |
/// </summary> |
||||||
public class HyperlinkIconButton : HyperlinkButton |
public class HyperlinkIconButton : Button |
||||||
{ |
{ |
||||||
/// <inheritdoc /> |
private Uri? _navigateUri; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Defines the <see cref="NavigateUri"/> property |
||||||
|
/// </summary> |
||||||
|
public static readonly DirectProperty<HyperlinkIconButton, Uri?> NavigateUriProperty = |
||||||
|
AvaloniaProperty.RegisterDirect<HyperlinkIconButton, Uri?>( |
||||||
|
nameof(NavigateUri), |
||||||
|
x => x.NavigateUri, |
||||||
|
(x, v) => x.NavigateUri = v |
||||||
|
); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets the Uri that the button should navigate to upon clicking. In assembly paths are not supported, (e.g., avares://...) |
||||||
|
/// </summary> |
||||||
|
public Uri? NavigateUri |
||||||
|
{ |
||||||
|
get => _navigateUri; |
||||||
|
set => SetAndRaise(NavigateUriProperty, ref _navigateUri, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<Symbol> IconProperty = AvaloniaProperty.Register< |
||||||
|
HyperlinkIconButton, |
||||||
|
Symbol |
||||||
|
>("Icon", Symbol.Link); |
||||||
|
|
||||||
|
public Symbol Icon |
||||||
|
{ |
||||||
|
get => GetValue(IconProperty); |
||||||
|
set => SetValue(IconProperty, value); |
||||||
|
} |
||||||
|
|
||||||
protected override Type StyleKeyOverride => typeof(HyperlinkIconButton); |
protected override Type StyleKeyOverride => typeof(HyperlinkIconButton); |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) |
||||||
|
{ |
||||||
|
base.OnPropertyChanged(change); |
||||||
|
|
||||||
|
// Update icon |
||||||
|
if (change.Property == NavigateUriProperty) |
||||||
|
{ |
||||||
|
var uri = change.GetNewValue<Uri?>(); |
||||||
|
|
||||||
|
if (uri is not null && uri.IsFile && Icon == Symbol.Link) |
||||||
|
{ |
||||||
|
Icon = Symbol.Open; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
protected override void OnClick() |
||||||
|
{ |
||||||
|
base.OnClick(); |
||||||
|
|
||||||
|
if (NavigateUri is null) |
||||||
|
return; |
||||||
|
|
||||||
|
// File or Folder URIs |
||||||
|
if (NavigateUri.IsFile) |
||||||
|
{ |
||||||
|
var path = NavigateUri.LocalPath; |
||||||
|
|
||||||
|
if (Directory.Exists(path)) |
||||||
|
{ |
||||||
|
ProcessRunner |
||||||
|
.OpenFolderBrowser(path) |
||||||
|
.SafeFireAndForget(ex => |
||||||
|
{ |
||||||
|
Logger.TryGet(LogEventLevel.Error, $"Unable to open directory Uri {NavigateUri}"); |
||||||
|
}); |
||||||
|
} |
||||||
|
else if (File.Exists(path)) |
||||||
|
{ |
||||||
|
ProcessRunner |
||||||
|
.OpenFileBrowser(path) |
||||||
|
.SafeFireAndForget(ex => |
||||||
|
{ |
||||||
|
Logger.TryGet(LogEventLevel.Error, $"Unable to open file Uri {NavigateUri}"); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
// Web |
||||||
|
else |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
Process.Start( |
||||||
|
new ProcessStartInfo(NavigateUri.ToString()) { UseShellExecute = true, Verb = "open" } |
||||||
|
); |
||||||
|
} |
||||||
|
catch |
||||||
|
{ |
||||||
|
Logger.TryGet(LogEventLevel.Error, $"Unable to open Uri {NavigateUri}"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
protected override bool RegisterContentPresenter(ContentPresenter presenter) |
||||||
|
{ |
||||||
|
return presenter.Name == "ContentPresenter"; |
||||||
|
} |
||||||
} |
} |
||||||
|
@ -0,0 +1,48 @@ |
|||||||
|
<Styles |
||||||
|
xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="using:StabilityMatrix.Avalonia.Controls" |
||||||
|
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" |
||||||
|
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" |
||||||
|
xmlns:vmInference="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Inference" |
||||||
|
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters" |
||||||
|
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||||
|
x:DataType="vmInference:LayerDiffuseCardViewModel"> |
||||||
|
<Design.PreviewWith> |
||||||
|
<Panel Width="400" Height="200"> |
||||||
|
<StackPanel Width="300" VerticalAlignment="Center"> |
||||||
|
<controls:LayerDiffuseCard DataContext="{x:Static mocks:DesignData.LayerDiffuseCardViewModel}"/> |
||||||
|
</StackPanel> |
||||||
|
</Panel> |
||||||
|
</Design.PreviewWith> |
||||||
|
|
||||||
|
<Style Selector="controls|LayerDiffuseCard"> |
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" /> |
||||||
|
<Setter Property="Template"> |
||||||
|
<ControlTemplate> |
||||||
|
<controls:Card Padding="12"> |
||||||
|
<sg:SpacedGrid |
||||||
|
ColumnDefinitions="Auto,*" |
||||||
|
ColumnSpacing="8" |
||||||
|
RowDefinitions="*,*,*,*" |
||||||
|
RowSpacing="0"> |
||||||
|
<!-- Mode Selection --> |
||||||
|
<TextBlock |
||||||
|
Grid.Column="0" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="Mode" |
||||||
|
TextAlignment="Left" /> |
||||||
|
|
||||||
|
<ui:FAComboBox |
||||||
|
Grid.Row="0" |
||||||
|
Grid.Column="1" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
DisplayMemberBinding="{Binding Converter={x:Static converters:EnumAttributeConverters.DisplayName}}" |
||||||
|
ItemsSource="{Binding AvailableModes}" |
||||||
|
SelectedItem="{Binding SelectedMode}" /> |
||||||
|
</sg:SpacedGrid> |
||||||
|
</controls:Card> |
||||||
|
</ControlTemplate> |
||||||
|
</Setter> |
||||||
|
</Style> |
||||||
|
</Styles> |
@ -0,0 +1,7 @@ |
|||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
[Transient] |
||||||
|
public class LayerDiffuseCard : TemplatedControl; |
@ -0,0 +1,96 @@ |
|||||||
|
<Styles |
||||||
|
xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="using:StabilityMatrix.Avalonia.Controls" |
||||||
|
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters" |
||||||
|
xmlns:inference="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Inference" |
||||||
|
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" |
||||||
|
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||||
|
xmlns:models="clr-namespace:StabilityMatrix.Core.Models;assembly=StabilityMatrix.Core" |
||||||
|
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" |
||||||
|
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" |
||||||
|
xmlns:input="clr-namespace:FluentAvalonia.UI.Input;assembly=FluentAvalonia" |
||||||
|
xmlns:fluentIcons="clr-namespace:FluentIcons.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent" |
||||||
|
xmlns:local="clr-namespace:StabilityMatrix.Avalonia" |
||||||
|
x:DataType="inference:PromptExpansionCardViewModel"> |
||||||
|
|
||||||
|
<Design.PreviewWith> |
||||||
|
<Panel Width="400" Height="200"> |
||||||
|
<StackPanel Width="300" VerticalAlignment="Center"> |
||||||
|
<controls:PromptExpansionCard /> |
||||||
|
</StackPanel> |
||||||
|
</Panel> |
||||||
|
</Design.PreviewWith> |
||||||
|
|
||||||
|
<Style Selector="controls|PromptExpansionCard"> |
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" /> |
||||||
|
<Setter Property="Template"> |
||||||
|
<ControlTemplate> |
||||||
|
<controls:Card Padding="12"> |
||||||
|
<sg:SpacedGrid |
||||||
|
ColumnDefinitions="Auto,*" |
||||||
|
ColumnSpacing="8" |
||||||
|
RowDefinitions="*,*,*,*" |
||||||
|
RowSpacing="0"> |
||||||
|
<!-- Model --> |
||||||
|
<TextBlock |
||||||
|
Grid.Column="0" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{x:Static lang:Resources.Label_Model}" |
||||||
|
TextAlignment="Left" /> |
||||||
|
|
||||||
|
<ui:FAComboBox |
||||||
|
x:Name="PART_ModelComboBox" |
||||||
|
Grid.Row="0" |
||||||
|
Grid.Column="1" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
ItemContainerTheme="{StaticResource FAComboBoxItemHybridModelTheme}" |
||||||
|
ItemsSource="{Binding ClientManager.PromptExpansionModels}" |
||||||
|
SelectedItem="{Binding SelectedModel}"> |
||||||
|
|
||||||
|
<ui:FAComboBox.Resources> |
||||||
|
<input:StandardUICommand x:Key="RemoteDownloadCommand" |
||||||
|
Command="{Binding RemoteDownloadCommand}" /> |
||||||
|
</ui:FAComboBox.Resources> |
||||||
|
|
||||||
|
<ui:FAComboBox.DataTemplates> |
||||||
|
<controls:HybridModelTemplateSelector> |
||||||
|
<DataTemplate x:Key="{x:Static models:HybridModelType.Downloadable}" DataType="models:HybridModelFile"> |
||||||
|
<Grid ColumnDefinitions="*,Auto"> |
||||||
|
<TextBlock Foreground="{DynamicResource ThemeGreyColor}" Text="{Binding ShortDisplayName}" /> |
||||||
|
<Button |
||||||
|
Grid.Column="1" |
||||||
|
Margin="8,0,0,0" |
||||||
|
Padding="0" |
||||||
|
Classes="transparent-full"> |
||||||
|
<fluentIcons:SymbolIcon |
||||||
|
VerticalAlignment="Center" |
||||||
|
FontSize="18" |
||||||
|
Foreground="{DynamicResource ThemeGreyColor}" |
||||||
|
IsFilled="True" |
||||||
|
Symbol="CloudArrowDown" /> |
||||||
|
</Button> |
||||||
|
</Grid> |
||||||
|
</DataTemplate> |
||||||
|
<DataTemplate x:Key="{x:Static models:HybridModelType.None}" DataType="models:HybridModelFile"> |
||||||
|
<TextBlock Text="{Binding ShortDisplayName}" /> |
||||||
|
</DataTemplate> |
||||||
|
</controls:HybridModelTemplateSelector> |
||||||
|
</ui:FAComboBox.DataTemplates> |
||||||
|
|
||||||
|
</ui:FAComboBox> |
||||||
|
|
||||||
|
<!--<controls:BetterComboBox |
||||||
|
Grid.Row="0" |
||||||
|
Grid.Column="1" |
||||||
|
Padding="8,6,4,6" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
ItemsSource="{Binding ClientManager.Upscalers}" |
||||||
|
SelectedItem="{Binding SelectedModel}" |
||||||
|
Theme="{StaticResource BetterComboBoxHybridModelTheme}" />--> |
||||||
|
</sg:SpacedGrid> |
||||||
|
</controls:Card> |
||||||
|
</ControlTemplate> |
||||||
|
</Setter> |
||||||
|
</Style> |
||||||
|
</Styles> |
@ -0,0 +1,52 @@ |
|||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Inference; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
[Transient] |
||||||
|
public class PromptExpansionCard : TemplatedControl |
||||||
|
{ |
||||||
|
/// <inheritdoc /> |
||||||
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e) |
||||||
|
{ |
||||||
|
base.OnApplyTemplate(e); |
||||||
|
|
||||||
|
var upscalerComboBox = e.NameScope.Find("PART_ModelComboBox") as FAComboBox; |
||||||
|
upscalerComboBox!.SelectionChanged += UpscalerComboBox_OnSelectionChanged; |
||||||
|
} |
||||||
|
|
||||||
|
private void UpscalerComboBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) |
||||||
|
{ |
||||||
|
if (e.AddedItems.Count == 0) |
||||||
|
return; |
||||||
|
|
||||||
|
var item = e.AddedItems[0]; |
||||||
|
if (item is HybridModelFile { IsDownloadable: true }) |
||||||
|
{ |
||||||
|
// Reset the selection |
||||||
|
e.Handled = true; |
||||||
|
|
||||||
|
if ( |
||||||
|
e.RemovedItems.Count > 0 |
||||||
|
&& e.RemovedItems[0] is HybridModelFile { IsDownloadable: false } removedItem |
||||||
|
) |
||||||
|
{ |
||||||
|
(sender as FAComboBox)!.SelectedItem = removedItem; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
(sender as FAComboBox)!.SelectedItem = null; |
||||||
|
} |
||||||
|
|
||||||
|
// Show dialog to download the model |
||||||
|
(DataContext as PromptExpansionCardViewModel)! |
||||||
|
.RemoteDownloadCommand.ExecuteAsync(item) |
||||||
|
.SafeFireAndForget(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,65 @@ |
|||||||
|
<Styles |
||||||
|
xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="using:StabilityMatrix.Avalonia.Controls" |
||||||
|
xmlns:system="using:System" |
||||||
|
xmlns:treeFileExplorer="clr-namespace:StabilityMatrix.Avalonia.Models.TreeFileExplorer" |
||||||
|
xmlns:mock="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||||
|
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" |
||||||
|
xmlns:fluentIcons="clr-namespace:FluentIcons.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent"> |
||||||
|
<Design.PreviewWith> |
||||||
|
<StackPanel Spacing="16"> |
||||||
|
<Panel Height="300" Margin="4"> |
||||||
|
<controls:TreeFileExplorer |
||||||
|
RootPath="{x:Static mock:DesignData.CurrentDirectory}" /> |
||||||
|
</Panel> |
||||||
|
|
||||||
|
<Panel Height="300" Margin="4"> |
||||||
|
<controls:TreeFileExplorer |
||||||
|
IndexFiles="False" |
||||||
|
CanSelectFiles="False" |
||||||
|
RootPath="{x:Static mock:DesignData.CurrentDirectory}" /> |
||||||
|
</Panel> |
||||||
|
</StackPanel> |
||||||
|
</Design.PreviewWith> |
||||||
|
|
||||||
|
<Style Selector="controls|TreeFileExplorer"> |
||||||
|
<!-- Set Defaults --> |
||||||
|
<Setter Property="Template"> |
||||||
|
<ControlTemplate> |
||||||
|
<Grid> |
||||||
|
<TreeView |
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Auto" |
||||||
|
ItemsSource="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=RootItem.Children}"> |
||||||
|
<TreeView.DataTemplates> |
||||||
|
<DataTemplate |
||||||
|
DataType="treeFileExplorer:TreeFileExplorerFile"> |
||||||
|
<sg:SpacedGrid ColumnDefinitions="Auto,*" RowSpacing="0" ColumnSpacing="4"> |
||||||
|
<fluentIcons:SymbolIcon |
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}" |
||||||
|
Symbol="Document" /> |
||||||
|
<TextBlock |
||||||
|
Grid.Column="1" |
||||||
|
Text="{Binding Path.Name}" /> |
||||||
|
</sg:SpacedGrid> |
||||||
|
</DataTemplate> |
||||||
|
<TreeDataTemplate |
||||||
|
DataType="treeFileExplorer:TreeFileExplorerDirectory" |
||||||
|
ItemsSource="{Binding Children}"> |
||||||
|
<sg:SpacedGrid ColumnDefinitions="Auto,*" RowSpacing="0" ColumnSpacing="4"> |
||||||
|
<fluentIcons:SymbolIcon |
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}" |
||||||
|
IsFilled="True" |
||||||
|
Symbol="Folder" /> |
||||||
|
<TextBlock |
||||||
|
Grid.Column="1" |
||||||
|
Text="{Binding Path.Name}" /> |
||||||
|
</sg:SpacedGrid> |
||||||
|
</TreeDataTemplate> |
||||||
|
</TreeView.DataTemplates> |
||||||
|
</TreeView> |
||||||
|
</Grid> |
||||||
|
</ControlTemplate> |
||||||
|
</Setter> |
||||||
|
</Style> |
||||||
|
</Styles> |
@ -0,0 +1,135 @@ |
|||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using StabilityMatrix.Avalonia.Models.TreeFileExplorer; |
||||||
|
using StabilityMatrix.Core.Models.FileInterfaces; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
public class TreeFileExplorer : TemplatedControl |
||||||
|
{ |
||||||
|
public static readonly StyledProperty<TreeFileExplorerDirectory?> RootItemProperty = |
||||||
|
AvaloniaProperty.Register<TreeFileExplorer, TreeFileExplorerDirectory?>("RootItem"); |
||||||
|
|
||||||
|
public TreeFileExplorerDirectory? RootItem |
||||||
|
{ |
||||||
|
get => GetValue(RootItemProperty); |
||||||
|
set => SetValue(RootItemProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<string?> RootPathProperty = AvaloniaProperty.Register< |
||||||
|
TreeFileExplorer, |
||||||
|
string? |
||||||
|
>("RootPath"); |
||||||
|
|
||||||
|
public string? RootPath |
||||||
|
{ |
||||||
|
get => GetValue(RootPathProperty); |
||||||
|
set => SetValue(RootPathProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<IPathObject?> SelectedPathProperty = AvaloniaProperty.Register< |
||||||
|
TreeFileExplorer, |
||||||
|
IPathObject? |
||||||
|
>("SelectedPath"); |
||||||
|
|
||||||
|
public IPathObject? SelectedPath |
||||||
|
{ |
||||||
|
get => GetValue(SelectedPathProperty); |
||||||
|
set => SetValue(SelectedPathProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> CanSelectFilesProperty = AvaloniaProperty.Register< |
||||||
|
TreeFileExplorer, |
||||||
|
bool |
||||||
|
>("CanSelectFiles", true); |
||||||
|
|
||||||
|
public bool CanSelectFiles |
||||||
|
{ |
||||||
|
get => GetValue(CanSelectFilesProperty); |
||||||
|
set => SetValue(CanSelectFilesProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> CanSelectFoldersProperty = AvaloniaProperty.Register< |
||||||
|
TreeFileExplorer, |
||||||
|
bool |
||||||
|
>("CanSelectFolders", true); |
||||||
|
|
||||||
|
public bool CanSelectFolders |
||||||
|
{ |
||||||
|
get => GetValue(CanSelectFoldersProperty); |
||||||
|
set => SetValue(CanSelectFoldersProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> IndexFilesProperty = AvaloniaProperty.Register< |
||||||
|
TreeFileExplorer, |
||||||
|
bool |
||||||
|
>("IndexFiles", true); |
||||||
|
|
||||||
|
public bool IndexFiles |
||||||
|
{ |
||||||
|
get => GetValue(IndexFilesProperty); |
||||||
|
set => SetValue(IndexFilesProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> IndexFoldersProperty = AvaloniaProperty.Register< |
||||||
|
TreeFileExplorer, |
||||||
|
bool |
||||||
|
>("IndexFolders", true); |
||||||
|
|
||||||
|
public bool IndexFolders |
||||||
|
{ |
||||||
|
get => GetValue(IndexFoldersProperty); |
||||||
|
set => SetValue(IndexFoldersProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
private TreeFileExplorerOptions GetOptions() |
||||||
|
{ |
||||||
|
var options = TreeFileExplorerOptions.None; |
||||||
|
|
||||||
|
if (CanSelectFiles) |
||||||
|
{ |
||||||
|
options |= TreeFileExplorerOptions.CanSelectFiles; |
||||||
|
} |
||||||
|
if (CanSelectFolders) |
||||||
|
{ |
||||||
|
options |= TreeFileExplorerOptions.CanSelectFolders; |
||||||
|
} |
||||||
|
if (IndexFiles) |
||||||
|
{ |
||||||
|
options |= TreeFileExplorerOptions.IndexFiles; |
||||||
|
} |
||||||
|
if (IndexFolders) |
||||||
|
{ |
||||||
|
options |= TreeFileExplorerOptions.IndexFolders; |
||||||
|
} |
||||||
|
|
||||||
|
return options; |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e) |
||||||
|
{ |
||||||
|
base.OnApplyTemplate(e); |
||||||
|
|
||||||
|
if (RootItem is null) |
||||||
|
{ |
||||||
|
RootItem = RootPath is null |
||||||
|
? null |
||||||
|
: new TreeFileExplorerDirectory(new DirectoryPath(RootPath), GetOptions()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) |
||||||
|
{ |
||||||
|
base.OnPropertyChanged(change); |
||||||
|
|
||||||
|
if (change.Property == RootPathProperty) |
||||||
|
{ |
||||||
|
var path = change.GetNewValue<string?>(); |
||||||
|
RootItem = path is null |
||||||
|
? null |
||||||
|
: new TreeFileExplorerDirectory(new DirectoryPath(path), GetOptions()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
using System; |
||||||
|
using Avalonia.Interactivity; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls.VendorLabs; |
||||||
|
|
||||||
|
public partial class BetterAsyncImage |
||||||
|
{ |
||||||
|
public class AsyncImageFailedEventArgs : RoutedEventArgs |
||||||
|
{ |
||||||
|
internal AsyncImageFailedEventArgs(Exception? errorException = null, string errorMessage = "") |
||||||
|
: base(FailedEvent) |
||||||
|
{ |
||||||
|
ErrorException = errorException; |
||||||
|
ErrorMessage = errorMessage; |
||||||
|
} |
||||||
|
|
||||||
|
public Exception? ErrorException { get; private set; } |
||||||
|
public string ErrorMessage { get; private set; } |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,42 @@ |
|||||||
|
using System; |
||||||
|
using Avalonia.Interactivity; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls.VendorLabs; |
||||||
|
|
||||||
|
public partial class BetterAsyncImage |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Deines the <see cref="Opened"/> event |
||||||
|
/// </summary> |
||||||
|
public static readonly RoutedEvent<RoutedEventArgs> OpenedEvent = RoutedEvent.Register< |
||||||
|
BetterAsyncImage, |
||||||
|
RoutedEventArgs |
||||||
|
>(nameof(Opened), RoutingStrategies.Bubble); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Deines the <see cref="Failed"/> event |
||||||
|
/// </summary> |
||||||
|
public static readonly RoutedEvent<global::Avalonia.Labs.Controls.AsyncImage.AsyncImageFailedEventArgs> FailedEvent = |
||||||
|
RoutedEvent.Register< |
||||||
|
BetterAsyncImage, |
||||||
|
global::Avalonia.Labs.Controls.AsyncImage.AsyncImageFailedEventArgs |
||||||
|
>(nameof(Failed), RoutingStrategies.Bubble); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Occurs when the image is successfully loaded. |
||||||
|
/// </summary> |
||||||
|
public event EventHandler<RoutedEventArgs>? Opened |
||||||
|
{ |
||||||
|
add => AddHandler(OpenedEvent, value); |
||||||
|
remove => RemoveHandler(OpenedEvent, value); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Occurs when the image fails to load the uri provided. |
||||||
|
/// </summary> |
||||||
|
public event EventHandler<global::Avalonia.Labs.Controls.AsyncImage.AsyncImageFailedEventArgs>? Failed |
||||||
|
{ |
||||||
|
add => AddHandler(FailedEvent, value); |
||||||
|
remove => RemoveHandler(FailedEvent, value); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,135 @@ |
|||||||
|
using System; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Animation; |
||||||
|
using Avalonia.Labs.Controls; |
||||||
|
using Avalonia.Media; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls.VendorLabs; |
||||||
|
|
||||||
|
public partial class BetterAsyncImage |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Defines the <see cref="PlaceholderSource"/> property. |
||||||
|
/// </summary> |
||||||
|
public static readonly StyledProperty<IImage?> PlaceholderSourceProperty = AvaloniaProperty.Register< |
||||||
|
BetterAsyncImage, |
||||||
|
IImage? |
||||||
|
>(nameof(PlaceholderSource)); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Defines the <see cref="Source"/> property. |
||||||
|
/// </summary> |
||||||
|
public static readonly StyledProperty<Uri?> SourceProperty = AvaloniaProperty.Register< |
||||||
|
BetterAsyncImage, |
||||||
|
Uri? |
||||||
|
>(nameof(Source)); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Defines the <see cref="Stretch"/> property. |
||||||
|
/// </summary> |
||||||
|
public static readonly StyledProperty<Stretch> StretchProperty = AvaloniaProperty.Register< |
||||||
|
BetterAsyncImage, |
||||||
|
Stretch |
||||||
|
>(nameof(Stretch), Stretch.Uniform); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Defines the <see cref="PlaceholderStretch"/> property. |
||||||
|
/// </summary> |
||||||
|
public static readonly StyledProperty<Stretch> PlaceholderStretchProperty = AvaloniaProperty.Register< |
||||||
|
BetterAsyncImage, |
||||||
|
Stretch |
||||||
|
>(nameof(PlaceholderStretch), Stretch.Uniform); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Defines the <see cref="State"/> property. |
||||||
|
/// </summary> |
||||||
|
public static readonly DirectProperty<BetterAsyncImage, AsyncImageState> StateProperty = |
||||||
|
AvaloniaProperty.RegisterDirect<BetterAsyncImage, AsyncImageState>( |
||||||
|
nameof(State), |
||||||
|
o => o.State, |
||||||
|
(o, v) => o.State = v |
||||||
|
); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Defines the <see cref="ImageTransition"/> property. |
||||||
|
/// </summary> |
||||||
|
public static readonly StyledProperty<IPageTransition?> ImageTransitionProperty = |
||||||
|
AvaloniaProperty.Register<BetterAsyncImage, IPageTransition?>( |
||||||
|
nameof(ImageTransition), |
||||||
|
new CrossFade(TimeSpan.FromSeconds(0.25)) |
||||||
|
); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Defines the <see cref="IsCacheEnabled"/> property. |
||||||
|
/// </summary> |
||||||
|
public static readonly DirectProperty<BetterAsyncImage, bool> IsCacheEnabledProperty = |
||||||
|
AvaloniaProperty.RegisterDirect<BetterAsyncImage, bool>( |
||||||
|
nameof(IsCacheEnabled), |
||||||
|
o => o.IsCacheEnabled, |
||||||
|
(o, v) => o.IsCacheEnabled = v |
||||||
|
); |
||||||
|
private bool _isCacheEnabled; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets the placeholder image. |
||||||
|
/// </summary> |
||||||
|
public IImage? PlaceholderSource |
||||||
|
{ |
||||||
|
get => GetValue(PlaceholderSourceProperty); |
||||||
|
set => SetValue(PlaceholderSourceProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets the uri pointing to the image resource |
||||||
|
/// </summary> |
||||||
|
public Uri? Source |
||||||
|
{ |
||||||
|
get => GetValue(SourceProperty); |
||||||
|
set => SetValue(SourceProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets a value controlling how the image will be stretched. |
||||||
|
/// </summary> |
||||||
|
public Stretch Stretch |
||||||
|
{ |
||||||
|
get { return GetValue(StretchProperty); } |
||||||
|
set { SetValue(StretchProperty, value); } |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets a value controlling how the placeholder will be stretched. |
||||||
|
/// </summary> |
||||||
|
public Stretch PlaceholderStretch |
||||||
|
{ |
||||||
|
get { return GetValue(StretchProperty); } |
||||||
|
set { SetValue(StretchProperty, value); } |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets the current loading state of the image. |
||||||
|
/// </summary> |
||||||
|
public AsyncImageState State |
||||||
|
{ |
||||||
|
get => _state; |
||||||
|
private set => SetAndRaise(StateProperty, ref _state, value); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets the transition to run when the image is loaded. |
||||||
|
/// </summary> |
||||||
|
public IPageTransition? ImageTransition |
||||||
|
{ |
||||||
|
get => GetValue(ImageTransitionProperty); |
||||||
|
set => SetValue(ImageTransitionProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets whether to use cache for retrieved images |
||||||
|
/// </summary> |
||||||
|
public bool IsCacheEnabled |
||||||
|
{ |
||||||
|
get => _isCacheEnabled; |
||||||
|
set => SetAndRaise(IsCacheEnabledProperty, ref _isCacheEnabled, value); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,248 @@ |
|||||||
|
using System; |
||||||
|
using System.IO; |
||||||
|
using System.Net.Http; |
||||||
|
using System.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Metadata; |
||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using Avalonia.Interactivity; |
||||||
|
using Avalonia.Labs.Controls; |
||||||
|
using Avalonia.Media; |
||||||
|
using Avalonia.Media.Imaging; |
||||||
|
using Avalonia.Platform; |
||||||
|
using Avalonia.Threading; |
||||||
|
using StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls.VendorLabs; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// An image control that asynchronously retrieves an image using a <see cref="Uri"/>. |
||||||
|
/// </summary> |
||||||
|
[TemplatePart("PART_Image", typeof(Image))] |
||||||
|
[TemplatePart("PART_PlaceholderImage", typeof(Image))] |
||||||
|
public partial class BetterAsyncImage : TemplatedControl |
||||||
|
{ |
||||||
|
protected Image? ImagePart { get; private set; } |
||||||
|
protected Image? PlaceholderPart { get; private set; } |
||||||
|
|
||||||
|
private bool _isInitialized; |
||||||
|
private CancellationTokenSource? _tokenSource; |
||||||
|
private AsyncImageState _state; |
||||||
|
|
||||||
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e) |
||||||
|
{ |
||||||
|
base.OnApplyTemplate(e); |
||||||
|
|
||||||
|
ImagePart = e.NameScope.Get<Image>("PART_Image"); |
||||||
|
PlaceholderPart = e.NameScope.Get<Image>("PART_PlaceholderImage"); |
||||||
|
|
||||||
|
_tokenSource = new CancellationTokenSource(); |
||||||
|
|
||||||
|
_isInitialized = true; |
||||||
|
|
||||||
|
if (Source != null) |
||||||
|
{ |
||||||
|
SetSource(Source); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private async void SetSource(object? source) |
||||||
|
{ |
||||||
|
if (!_isInitialized) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
_tokenSource?.Cancel(); |
||||||
|
|
||||||
|
_tokenSource = new CancellationTokenSource(); |
||||||
|
|
||||||
|
AttachSource(null); |
||||||
|
|
||||||
|
if (source == null) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
State = AsyncImageState.Loading; |
||||||
|
|
||||||
|
if (Source is IImage image) |
||||||
|
{ |
||||||
|
AttachSource(image); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (Source == null) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var uri = Source; |
||||||
|
|
||||||
|
if (uri != null && uri.IsAbsoluteUri) |
||||||
|
{ |
||||||
|
if (uri.Scheme == "http" || uri.Scheme == "https") |
||||||
|
{ |
||||||
|
Bitmap? bitmap = null; |
||||||
|
// Android doesn't allow network requests on the main thread, even though we are using async apis. |
||||||
|
#if NET6_0_OR_GREATER |
||||||
|
if (OperatingSystem.IsAndroid()) |
||||||
|
{ |
||||||
|
await Task.Run(async () => |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
bitmap = await LoadImageAsync(uri, _tokenSource.Token); |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => |
||||||
|
{ |
||||||
|
State = AsyncImageState.Failed; |
||||||
|
|
||||||
|
RaiseEvent(new AsyncImageFailedEventArgs(ex)); |
||||||
|
}); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
else |
||||||
|
#endif |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
bitmap = await LoadImageAsync(uri, _tokenSource.Token); |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => |
||||||
|
{ |
||||||
|
State = AsyncImageState.Failed; |
||||||
|
|
||||||
|
RaiseEvent(new AsyncImageFailedEventArgs(ex)); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
AttachSource(bitmap); |
||||||
|
} |
||||||
|
else if (uri.Scheme == "avares") |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
AttachSource(new Bitmap(AssetLoader.Open(uri))); |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
State = AsyncImageState.Failed; |
||||||
|
|
||||||
|
RaiseEvent(new AsyncImageFailedEventArgs(ex)); |
||||||
|
} |
||||||
|
} |
||||||
|
else if (uri.Scheme == "file" && File.Exists(uri.LocalPath)) |
||||||
|
{ |
||||||
|
// Added error handling here for local files |
||||||
|
try |
||||||
|
{ |
||||||
|
AttachSource(new Bitmap(uri.LocalPath)); |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
State = AsyncImageState.Failed; |
||||||
|
|
||||||
|
RaiseEvent(new AsyncImageFailedEventArgs(ex)); |
||||||
|
} |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
RaiseEvent( |
||||||
|
new AsyncImageFailedEventArgs( |
||||||
|
new UriFormatException($"Uri has unsupported scheme. Uri:{source}") |
||||||
|
) |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
RaiseEvent( |
||||||
|
new AsyncImageFailedEventArgs( |
||||||
|
new UriFormatException($"Relative paths aren't supported. Uri:{source}") |
||||||
|
) |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void AttachSource(IImage? image) |
||||||
|
{ |
||||||
|
if (ImagePart != null) |
||||||
|
{ |
||||||
|
ImagePart.Source = image; |
||||||
|
} |
||||||
|
|
||||||
|
_tokenSource?.Cancel(); |
||||||
|
_tokenSource = new CancellationTokenSource(); |
||||||
|
|
||||||
|
if (image == null) |
||||||
|
{ |
||||||
|
State = AsyncImageState.Unloaded; |
||||||
|
|
||||||
|
ImageTransition?.Start(ImagePart, PlaceholderPart, true, _tokenSource.Token); |
||||||
|
} |
||||||
|
else if (image.Size != default) |
||||||
|
{ |
||||||
|
State = AsyncImageState.Loaded; |
||||||
|
|
||||||
|
ImageTransition?.Start(PlaceholderPart, ImagePart, true, _tokenSource.Token); |
||||||
|
|
||||||
|
RaiseEvent(new RoutedEventArgs(OpenedEvent)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<Bitmap> LoadImageAsync(Uri? url, CancellationToken token) |
||||||
|
{ |
||||||
|
if (await ProvideCachedResourceAsync(url, token) is { } bitmap) |
||||||
|
{ |
||||||
|
return bitmap; |
||||||
|
} |
||||||
|
#if NET6_0_OR_GREATER |
||||||
|
using var client = new HttpClient(); |
||||||
|
var stream = await client.GetStreamAsync(url, token).ConfigureAwait(false); |
||||||
|
|
||||||
|
await using var memoryStream = new MemoryStream(); |
||||||
|
await stream.CopyToAsync(memoryStream, token).ConfigureAwait(false); |
||||||
|
#elif NETSTANDARD2_0 |
||||||
|
using var client = new HttpClient(); |
||||||
|
var response = await client.GetAsync(url, token).ConfigureAwait(false); |
||||||
|
var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); |
||||||
|
|
||||||
|
using var memoryStream = new MemoryStream(); |
||||||
|
await stream.CopyToAsync(memoryStream).ConfigureAwait(false); |
||||||
|
#endif |
||||||
|
|
||||||
|
memoryStream.Position = 0; |
||||||
|
return new Bitmap(memoryStream); |
||||||
|
} |
||||||
|
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) |
||||||
|
{ |
||||||
|
base.OnPropertyChanged(change); |
||||||
|
|
||||||
|
if (change.Property == SourceProperty) |
||||||
|
{ |
||||||
|
SetSource(Source); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
protected virtual async Task<Bitmap?> ProvideCachedResourceAsync(Uri? imageUri, CancellationToken token) |
||||||
|
{ |
||||||
|
if (IsCacheEnabled && imageUri != null) |
||||||
|
{ |
||||||
|
return await ImageCache |
||||||
|
.Instance.GetFromCacheAsync(imageUri, cancellationToken: token) |
||||||
|
.ConfigureAwait(false); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,565 @@ |
|||||||
|
// Parts of this file was taken from Windows Community Toolkit CacheBase implementation |
||||||
|
// https://github.com/CommunityToolkit/WindowsCommunityToolkit/blob/main/Microsoft.Toolkit.Uwp.UI/Cache/ImageCache.cs |
||||||
|
|
||||||
|
// Licensed to the .NET Foundation under one or more agreements. |
||||||
|
// The .NET Foundation licenses this file to you under the MIT license. |
||||||
|
// See the LICENSE file in the project root for more information. |
||||||
|
|
||||||
|
using System; |
||||||
|
using System.Collections.Concurrent; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Net.Http; |
||||||
|
using System.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; |
||||||
|
|
||||||
|
internal abstract class CacheBase<T> |
||||||
|
{ |
||||||
|
private class ConcurrentRequest |
||||||
|
{ |
||||||
|
public Task<T?>? Task { get; set; } |
||||||
|
|
||||||
|
public bool EnsureCachedCopy { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
private readonly SemaphoreSlim _cacheFolderSemaphore = new SemaphoreSlim(1); |
||||||
|
private string? _baseFolder = null; |
||||||
|
private string? _cacheFolderName = null; |
||||||
|
|
||||||
|
private string? _cacheFolder = null; |
||||||
|
private InMemoryStorage<T>? _inMemoryFileStorage = null; |
||||||
|
|
||||||
|
private ConcurrentDictionary<string, ConcurrentRequest> _concurrentTasks = |
||||||
|
new ConcurrentDictionary<string, ConcurrentRequest>(); |
||||||
|
|
||||||
|
private HttpClient? _httpClient = null; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Initializes a new instance of the <see cref="CacheBase{T}"/> class. |
||||||
|
/// </summary> |
||||||
|
protected CacheBase() |
||||||
|
{ |
||||||
|
var options = CacheOptions.Default; |
||||||
|
CacheDuration = options?.CacheDuration ?? TimeSpan.FromDays(1); |
||||||
|
_baseFolder = options?.BaseCachePath ?? null; |
||||||
|
_inMemoryFileStorage = new InMemoryStorage<T>(); |
||||||
|
RetryCount = 1; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets the life duration of every cache entry. |
||||||
|
/// </summary> |
||||||
|
public TimeSpan CacheDuration { get; set; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets the number of retries trying to ensure the file is cached. |
||||||
|
/// </summary> |
||||||
|
public uint RetryCount { get; set; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets max in-memory item storage count |
||||||
|
/// </summary> |
||||||
|
public int MaxMemoryCacheCount |
||||||
|
{ |
||||||
|
get { return _inMemoryFileStorage?.MaxItemCount ?? 0; } |
||||||
|
set |
||||||
|
{ |
||||||
|
if (_inMemoryFileStorage != null) |
||||||
|
_inMemoryFileStorage.MaxItemCount = value; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets instance of <see cref="HttpClient"/> |
||||||
|
/// </summary> |
||||||
|
protected HttpClient HttpClient |
||||||
|
{ |
||||||
|
get |
||||||
|
{ |
||||||
|
if (_httpClient == null) |
||||||
|
{ |
||||||
|
var messageHandler = new HttpClientHandler(); |
||||||
|
|
||||||
|
_httpClient = new HttpClient(messageHandler); |
||||||
|
} |
||||||
|
|
||||||
|
return _httpClient; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Initializes FileCache and provides root folder and cache folder name |
||||||
|
/// </summary> |
||||||
|
/// <param name="folder">Folder that is used as root for cache</param> |
||||||
|
/// <param name="folderName">Cache folder name</param> |
||||||
|
/// <param name="httpMessageHandler">instance of <see cref="HttpMessageHandler"/></param> |
||||||
|
/// <returns>awaitable task</returns> |
||||||
|
public virtual async Task InitializeAsync( |
||||||
|
string? folder = null, |
||||||
|
string? folderName = null, |
||||||
|
HttpMessageHandler? httpMessageHandler = null |
||||||
|
) |
||||||
|
{ |
||||||
|
_baseFolder = folder; |
||||||
|
_cacheFolderName = folderName; |
||||||
|
|
||||||
|
_cacheFolder = await GetCacheFolderAsync().ConfigureAwait(false); |
||||||
|
|
||||||
|
if (httpMessageHandler != null) |
||||||
|
{ |
||||||
|
_httpClient = new HttpClient(httpMessageHandler); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Clears all files in the cache |
||||||
|
/// </summary> |
||||||
|
/// <returns>awaitable task</returns> |
||||||
|
public async Task ClearAsync() |
||||||
|
{ |
||||||
|
var folder = await GetCacheFolderAsync().ConfigureAwait(false); |
||||||
|
var files = Directory.EnumerateFiles(folder!); |
||||||
|
|
||||||
|
await InternalClearAsync(files.Select(x => x as string)).ConfigureAwait(false); |
||||||
|
|
||||||
|
_inMemoryFileStorage?.Clear(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Clears file if it has expired |
||||||
|
/// </summary> |
||||||
|
/// <param name="duration">timespan to compute whether file has expired or not</param> |
||||||
|
/// <returns>awaitable task</returns> |
||||||
|
public Task ClearAsync(TimeSpan duration) |
||||||
|
{ |
||||||
|
return RemoveExpiredAsync(duration); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Removes cached files that have expired |
||||||
|
/// </summary> |
||||||
|
/// <param name="duration">Optional timespan to compute whether file has expired or not. If no value is supplied, <see cref="CacheDuration"/> is used.</param> |
||||||
|
/// <returns>awaitable task</returns> |
||||||
|
public async Task RemoveExpiredAsync(TimeSpan? duration = null) |
||||||
|
{ |
||||||
|
TimeSpan expiryDuration = duration ?? CacheDuration; |
||||||
|
|
||||||
|
var folder = await GetCacheFolderAsync().ConfigureAwait(false); |
||||||
|
var files = Directory.EnumerateFiles(folder!); |
||||||
|
|
||||||
|
var filesToDelete = new List<string>(); |
||||||
|
|
||||||
|
foreach (var file in files) |
||||||
|
{ |
||||||
|
if (file == null) |
||||||
|
{ |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (await IsFileOutOfDateAsync(file, expiryDuration, false).ConfigureAwait(false)) |
||||||
|
{ |
||||||
|
filesToDelete.Add(file); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
await InternalClearAsync(filesToDelete).ConfigureAwait(false); |
||||||
|
|
||||||
|
_inMemoryFileStorage?.Clear(expiryDuration); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Removed items based on uri list passed |
||||||
|
/// </summary> |
||||||
|
/// <param name="uriForCachedItems">Enumerable uri list</param> |
||||||
|
/// <returns>awaitable Task</returns> |
||||||
|
public async Task RemoveAsync(IEnumerable<Uri> uriForCachedItems) |
||||||
|
{ |
||||||
|
if (uriForCachedItems == null || !uriForCachedItems.Any()) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var folder = await GetCacheFolderAsync().ConfigureAwait(false); |
||||||
|
var files = Directory.EnumerateFiles(folder!); |
||||||
|
var filesToDelete = new List<string>(); |
||||||
|
var keys = new List<string>(); |
||||||
|
|
||||||
|
Dictionary<string, string> hashDictionary = new Dictionary<string, string>(); |
||||||
|
|
||||||
|
foreach (var file in files) |
||||||
|
{ |
||||||
|
hashDictionary.Add(Path.GetFileName(file), file); |
||||||
|
} |
||||||
|
|
||||||
|
foreach (var uri in uriForCachedItems) |
||||||
|
{ |
||||||
|
string fileName = GetCacheFileName(uri); |
||||||
|
if (hashDictionary.TryGetValue(fileName, out var file)) |
||||||
|
{ |
||||||
|
filesToDelete.Add(file); |
||||||
|
keys.Add(fileName); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
await InternalClearAsync(filesToDelete).ConfigureAwait(false); |
||||||
|
|
||||||
|
_inMemoryFileStorage?.Remove(keys); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Assures that item represented by Uri is cached. |
||||||
|
/// </summary> |
||||||
|
/// <param name="uri">Uri of the item</param> |
||||||
|
/// <param name="throwOnError">Indicates whether or not exception should be thrown if item cannot be cached</param> |
||||||
|
/// <param name="storeToMemoryCache">Indicates if item should be loaded into the in-memory storage</param> |
||||||
|
/// <param name="cancellationToken">instance of <see cref="CancellationToken"/></param> |
||||||
|
/// <returns>Awaitable Task</returns> |
||||||
|
public Task PreCacheAsync( |
||||||
|
Uri uri, |
||||||
|
bool throwOnError = false, |
||||||
|
bool storeToMemoryCache = false, |
||||||
|
CancellationToken cancellationToken = default(CancellationToken) |
||||||
|
) |
||||||
|
{ |
||||||
|
return GetItemAsync(uri, throwOnError, !storeToMemoryCache, cancellationToken); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Retrieves item represented by Uri from the cache. If the item is not found in the cache, it will try to downloaded and saved before returning it to the caller. |
||||||
|
/// </summary> |
||||||
|
/// <param name="uri">Uri of the item.</param> |
||||||
|
/// <param name="throwOnError">Indicates whether or not exception should be thrown if item cannot be found / downloaded.</param> |
||||||
|
/// <param name="cancellationToken">instance of <see cref="CancellationToken"/></param> |
||||||
|
/// <returns>an instance of Generic type</returns> |
||||||
|
public Task<T?> GetFromCacheAsync( |
||||||
|
Uri uri, |
||||||
|
bool throwOnError = false, |
||||||
|
CancellationToken cancellationToken = default(CancellationToken) |
||||||
|
) |
||||||
|
{ |
||||||
|
return GetItemAsync(uri, throwOnError, false, cancellationToken); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets the string containing cached item for given Uri |
||||||
|
/// </summary> |
||||||
|
/// <param name="uri">Uri of the item.</param> |
||||||
|
/// <returns>a string</returns> |
||||||
|
public async Task<string> GetFileFromCacheAsync(Uri uri) |
||||||
|
{ |
||||||
|
var folder = await GetCacheFolderAsync().ConfigureAwait(false); |
||||||
|
|
||||||
|
return Path.Combine(folder!, GetCacheFileName(uri)); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Retrieves item represented by Uri from the in-memory cache if it exists and is not out of date. If item is not found or is out of date, default instance of the generic type is returned. |
||||||
|
/// </summary> |
||||||
|
/// <param name="uri">Uri of the item.</param> |
||||||
|
/// <returns>an instance of Generic type</returns> |
||||||
|
public T? GetFromMemoryCache(Uri uri) |
||||||
|
{ |
||||||
|
T? instance = default(T); |
||||||
|
|
||||||
|
string fileName = GetCacheFileName(uri); |
||||||
|
|
||||||
|
if (_inMemoryFileStorage?.MaxItemCount > 0) |
||||||
|
{ |
||||||
|
var msi = _inMemoryFileStorage?.GetItem(fileName, CacheDuration); |
||||||
|
if (msi != null) |
||||||
|
{ |
||||||
|
instance = msi.Item; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return instance; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Cache specific hooks to process items from HTTP response |
||||||
|
/// </summary> |
||||||
|
/// <param name="stream">input stream</param> |
||||||
|
/// <returns>awaitable task</returns> |
||||||
|
protected abstract Task<T> ConvertFromAsync(Stream stream); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Cache specific hooks to process items from HTTP response |
||||||
|
/// </summary> |
||||||
|
/// <param name="baseFile">storage file</param> |
||||||
|
/// <returns>awaitable task</returns> |
||||||
|
protected abstract Task<T> ConvertFromAsync(string baseFile); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Override-able method that checks whether file is valid or not. |
||||||
|
/// </summary> |
||||||
|
/// <param name="file">storage file</param> |
||||||
|
/// <param name="duration">cache duration</param> |
||||||
|
/// <param name="treatNullFileAsOutOfDate">option to mark uninitialized file as expired</param> |
||||||
|
/// <returns>bool indicate whether file has expired or not</returns> |
||||||
|
protected virtual async Task<bool> IsFileOutOfDateAsync( |
||||||
|
string file, |
||||||
|
TimeSpan duration, |
||||||
|
bool treatNullFileAsOutOfDate = true |
||||||
|
) |
||||||
|
{ |
||||||
|
if (file == null) |
||||||
|
{ |
||||||
|
return treatNullFileAsOutOfDate; |
||||||
|
} |
||||||
|
|
||||||
|
var info = new FileInfo(file); |
||||||
|
|
||||||
|
return info.Length == 0 || DateTime.Now.Subtract(info.LastWriteTime) > duration; |
||||||
|
} |
||||||
|
|
||||||
|
private static string GetCacheFileName(Uri uri) |
||||||
|
{ |
||||||
|
return CreateHash64(uri.ToString()).ToString(); |
||||||
|
} |
||||||
|
|
||||||
|
private static ulong CreateHash64(string str) |
||||||
|
{ |
||||||
|
byte[] utf8 = System.Text.Encoding.UTF8.GetBytes(str); |
||||||
|
|
||||||
|
ulong value = (ulong)utf8.Length; |
||||||
|
for (int n = 0; n < utf8.Length; n++) |
||||||
|
{ |
||||||
|
value += (ulong)utf8[n] << ((n * 5) % 56); |
||||||
|
} |
||||||
|
|
||||||
|
return value; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<T?> GetItemAsync( |
||||||
|
Uri uri, |
||||||
|
bool throwOnError, |
||||||
|
bool preCacheOnly, |
||||||
|
CancellationToken cancellationToken |
||||||
|
) |
||||||
|
{ |
||||||
|
T? instance = default(T); |
||||||
|
|
||||||
|
string fileName = GetCacheFileName(uri); |
||||||
|
_concurrentTasks.TryGetValue(fileName, out var request); |
||||||
|
|
||||||
|
// if similar request exists check if it was preCacheOnly and validate that current request isn't preCacheOnly |
||||||
|
if (request != null && request.EnsureCachedCopy && !preCacheOnly) |
||||||
|
{ |
||||||
|
if (request.Task != null) |
||||||
|
await request.Task.ConfigureAwait(false); |
||||||
|
request = null; |
||||||
|
} |
||||||
|
|
||||||
|
if (request == null) |
||||||
|
{ |
||||||
|
request = new ConcurrentRequest() |
||||||
|
{ |
||||||
|
Task = GetFromCacheOrDownloadAsync(uri, fileName, preCacheOnly, cancellationToken), |
||||||
|
EnsureCachedCopy = preCacheOnly |
||||||
|
}; |
||||||
|
|
||||||
|
_concurrentTasks[fileName] = request; |
||||||
|
} |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
if (request.Task != null) |
||||||
|
instance = await request.Task.ConfigureAwait(false); |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
System.Diagnostics.Debug.WriteLine(ex.Message); |
||||||
|
if (throwOnError) |
||||||
|
{ |
||||||
|
throw; |
||||||
|
} |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
_concurrentTasks.TryRemove(fileName, out _); |
||||||
|
} |
||||||
|
|
||||||
|
return instance; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<T?> GetFromCacheOrDownloadAsync( |
||||||
|
Uri uri, |
||||||
|
string fileName, |
||||||
|
bool preCacheOnly, |
||||||
|
CancellationToken cancellationToken |
||||||
|
) |
||||||
|
{ |
||||||
|
T? instance = default(T); |
||||||
|
|
||||||
|
if (_inMemoryFileStorage?.MaxItemCount > 0) |
||||||
|
{ |
||||||
|
var msi = _inMemoryFileStorage?.GetItem(fileName, CacheDuration); |
||||||
|
if (msi != null) |
||||||
|
{ |
||||||
|
instance = msi.Item; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (instance != null) |
||||||
|
{ |
||||||
|
return instance; |
||||||
|
} |
||||||
|
|
||||||
|
var folder = await GetCacheFolderAsync().ConfigureAwait(false); |
||||||
|
var baseFile = Path.Combine(folder!, fileName); |
||||||
|
|
||||||
|
bool downloadDataFile = |
||||||
|
!File.Exists(baseFile) |
||||||
|
|| await IsFileOutOfDateAsync(baseFile, CacheDuration).ConfigureAwait(false); |
||||||
|
|
||||||
|
if (!File.Exists(baseFile)) |
||||||
|
{ |
||||||
|
File.Create(baseFile).Dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
if (downloadDataFile) |
||||||
|
{ |
||||||
|
uint retries = 0; |
||||||
|
try |
||||||
|
{ |
||||||
|
while (retries < RetryCount) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
instance = await DownloadFileAsync(uri, baseFile, preCacheOnly, cancellationToken) |
||||||
|
.ConfigureAwait(false); |
||||||
|
|
||||||
|
if (instance != null) |
||||||
|
{ |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
catch (FileNotFoundException) { } |
||||||
|
|
||||||
|
retries++; |
||||||
|
} |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
File.Delete(baseFile); |
||||||
|
throw; // re-throwing the exception changes the stack trace. just throw |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (EqualityComparer<T>.Default.Equals(instance, default(T)) && !preCacheOnly) |
||||||
|
{ |
||||||
|
instance = await ConvertFromAsync(baseFile).ConfigureAwait(false); |
||||||
|
|
||||||
|
if (_inMemoryFileStorage?.MaxItemCount > 0) |
||||||
|
{ |
||||||
|
var properties = new FileInfo(baseFile); |
||||||
|
|
||||||
|
var msi = new InMemoryStorageItem<T>(fileName, properties.LastWriteTime, instance); |
||||||
|
_inMemoryFileStorage?.SetItem(msi); |
||||||
|
} |
||||||
|
} |
||||||
|
return instance; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<T?> DownloadFileAsync( |
||||||
|
Uri uri, |
||||||
|
string baseFile, |
||||||
|
bool preCacheOnly, |
||||||
|
CancellationToken cancellationToken |
||||||
|
) |
||||||
|
{ |
||||||
|
T? instance = default(T); |
||||||
|
|
||||||
|
using (MemoryStream ms = new MemoryStream()) |
||||||
|
{ |
||||||
|
using (var stream = await HttpClient.GetStreamAsync(uri)) |
||||||
|
{ |
||||||
|
stream.CopyTo(ms); |
||||||
|
ms.Flush(); |
||||||
|
|
||||||
|
ms.Position = 0; |
||||||
|
|
||||||
|
using (var fs = File.Open(baseFile, FileMode.OpenOrCreate, FileAccess.Write)) |
||||||
|
{ |
||||||
|
ms.CopyTo(fs); |
||||||
|
|
||||||
|
fs.Flush(); |
||||||
|
|
||||||
|
ms.Position = 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// if its pre-cache we aren't looking to load items in memory |
||||||
|
if (!preCacheOnly) |
||||||
|
{ |
||||||
|
instance = await ConvertFromAsync(ms).ConfigureAwait(false); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return instance; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task InternalClearAsync(IEnumerable<string?> files) |
||||||
|
{ |
||||||
|
foreach (var file in files) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
File.Delete(file!); |
||||||
|
} |
||||||
|
catch |
||||||
|
{ |
||||||
|
// Just ignore errors for now} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Initializes with default values if user has not initialized explicitly |
||||||
|
/// </summary> |
||||||
|
/// <returns>awaitable task</returns> |
||||||
|
private async Task ForceInitialiseAsync() |
||||||
|
{ |
||||||
|
if (_cacheFolder != null) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
await _cacheFolderSemaphore.WaitAsync().ConfigureAwait(false); |
||||||
|
|
||||||
|
_inMemoryFileStorage = new InMemoryStorage<T>(); |
||||||
|
|
||||||
|
if (_baseFolder == null) |
||||||
|
{ |
||||||
|
_baseFolder = Path.GetTempPath(); |
||||||
|
} |
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_cacheFolderName)) |
||||||
|
{ |
||||||
|
_cacheFolderName = GetType().Name; |
||||||
|
} |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
_cacheFolder = Path.Combine(_baseFolder, _cacheFolderName); |
||||||
|
Directory.CreateDirectory(_cacheFolder); |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
_cacheFolderSemaphore.Release(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<string?> GetCacheFolderAsync() |
||||||
|
{ |
||||||
|
if (_cacheFolder == null) |
||||||
|
{ |
||||||
|
await ForceInitialiseAsync().ConfigureAwait(false); |
||||||
|
} |
||||||
|
|
||||||
|
return _cacheFolder; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,18 @@ |
|||||||
|
using System; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; |
||||||
|
|
||||||
|
public class CacheOptions |
||||||
|
{ |
||||||
|
private static CacheOptions? _cacheOptions; |
||||||
|
|
||||||
|
public static CacheOptions Default => _cacheOptions ??= new CacheOptions(); |
||||||
|
|
||||||
|
public static void SetDefault(CacheOptions defaultCacheOptions) |
||||||
|
{ |
||||||
|
_cacheOptions = defaultCacheOptions; |
||||||
|
} |
||||||
|
|
||||||
|
public string? BaseCachePath { get; set; } |
||||||
|
public TimeSpan? CacheDuration { get; set; } |
||||||
|
} |
@ -0,0 +1,36 @@ |
|||||||
|
using System.IO; |
||||||
|
using System.Threading.Tasks; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Provides methods and tools to cache files in a folder |
||||||
|
/// </summary> |
||||||
|
internal class FileCache : CacheBase<string> |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Private singleton field. |
||||||
|
/// </summary> |
||||||
|
private static FileCache? _instance; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets public singleton property. |
||||||
|
/// </summary> |
||||||
|
public static FileCache Instance => _instance ?? (_instance = new FileCache()); |
||||||
|
|
||||||
|
protected override Task<string> ConvertFromAsync(Stream stream) |
||||||
|
{ |
||||||
|
// nothing to do in this instance; |
||||||
|
return Task.FromResult<string>(""); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Returns a cached path |
||||||
|
/// </summary> |
||||||
|
/// <param name="baseFile">storage file</param> |
||||||
|
/// <returns>awaitable task</returns> |
||||||
|
protected override Task<string> ConvertFromAsync(string baseFile) |
||||||
|
{ |
||||||
|
return Task.FromResult(baseFile); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,76 @@ |
|||||||
|
using System; |
||||||
|
using System.IO; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Avalonia.Media.Imaging; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Provides methods and tools to cache images in a folder |
||||||
|
/// </summary> |
||||||
|
internal class ImageCache : CacheBase<Bitmap> |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Private singleton field. |
||||||
|
/// </summary> |
||||||
|
[ThreadStatic] |
||||||
|
private static ImageCache? _instance; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets public singleton property. |
||||||
|
/// </summary> |
||||||
|
public static ImageCache Instance => _instance ?? (_instance = new ImageCache()); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Creates a bitmap from a stream |
||||||
|
/// </summary> |
||||||
|
/// <param name="stream">input stream</param> |
||||||
|
/// <returns>awaitable task</returns> |
||||||
|
protected override async Task<Bitmap> ConvertFromAsync(Stream stream) |
||||||
|
{ |
||||||
|
if (stream.Length == 0) |
||||||
|
{ |
||||||
|
throw new FileNotFoundException(); |
||||||
|
} |
||||||
|
|
||||||
|
return new Bitmap(stream); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Creates a bitmap from a cached file |
||||||
|
/// </summary> |
||||||
|
/// <param name="baseFile">file</param> |
||||||
|
/// <returns>awaitable task</returns> |
||||||
|
protected override async Task<Bitmap> ConvertFromAsync(string baseFile) |
||||||
|
{ |
||||||
|
using (var stream = File.OpenRead(baseFile)) |
||||||
|
{ |
||||||
|
return await ConvertFromAsync(stream).ConfigureAwait(false); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Checks whether file is valid or not. |
||||||
|
/// </summary> |
||||||
|
/// <param name="file">file</param> |
||||||
|
/// <param name="duration">cache duration</param> |
||||||
|
/// <param name="treatNullFileAsOutOfDate">option to mark uninitialized file as expired</param> |
||||||
|
/// <returns>bool indicate whether file has expired or not</returns> |
||||||
|
protected override async Task<bool> IsFileOutOfDateAsync( |
||||||
|
string file, |
||||||
|
TimeSpan duration, |
||||||
|
bool treatNullFileAsOutOfDate = true |
||||||
|
) |
||||||
|
{ |
||||||
|
if (file == null) |
||||||
|
{ |
||||||
|
return treatNullFileAsOutOfDate; |
||||||
|
} |
||||||
|
|
||||||
|
var fileInfo = new FileInfo(file); |
||||||
|
|
||||||
|
return fileInfo.Length == 0 |
||||||
|
|| DateTime.Now.Subtract(File.GetLastAccessTime(file)) > duration |
||||||
|
|| DateTime.Now.Subtract(File.GetLastWriteTime(file)) > duration; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,156 @@ |
|||||||
|
// Licensed to the .NET Foundation under one or more agreements. |
||||||
|
// The .NET Foundation licenses this file to you under the MIT license. |
||||||
|
// See the LICENSE file in the project root for more information. |
||||||
|
|
||||||
|
using System; |
||||||
|
using System.Collections.Concurrent; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Linq; |
||||||
|
using Avalonia.Labs.Controls.Cache; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Generic in-memory storage of items |
||||||
|
/// </summary> |
||||||
|
/// <typeparam name="T">T defines the type of item stored</typeparam> |
||||||
|
public class InMemoryStorage<T> |
||||||
|
{ |
||||||
|
private int _maxItemCount; |
||||||
|
private ConcurrentDictionary<string, InMemoryStorageItem<T>> _inMemoryStorage = |
||||||
|
new ConcurrentDictionary<string, InMemoryStorageItem<T>>(); |
||||||
|
private object _settingMaxItemCountLocker = new object(); |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets or sets the maximum count of Items that can be stored in this InMemoryStorage instance. |
||||||
|
/// </summary> |
||||||
|
public int MaxItemCount |
||||||
|
{ |
||||||
|
get { return _maxItemCount; } |
||||||
|
set |
||||||
|
{ |
||||||
|
if (_maxItemCount == value) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
_maxItemCount = value; |
||||||
|
|
||||||
|
lock (_settingMaxItemCountLocker) |
||||||
|
{ |
||||||
|
EnsureStorageBounds(value); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Clears all items stored in memory |
||||||
|
/// </summary> |
||||||
|
public void Clear() |
||||||
|
{ |
||||||
|
_inMemoryStorage.Clear(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Clears items stored in memory based on duration passed |
||||||
|
/// </summary> |
||||||
|
/// <param name="duration">TimeSpan to identify expired items</param> |
||||||
|
public void Clear(TimeSpan duration) |
||||||
|
{ |
||||||
|
var expirationDate = DateTime.Now.Subtract(duration); |
||||||
|
|
||||||
|
var itemsToRemove = _inMemoryStorage |
||||||
|
.Where(kvp => kvp.Value.LastUpdated <= expirationDate) |
||||||
|
.Select(kvp => kvp.Key); |
||||||
|
|
||||||
|
if (itemsToRemove.Any()) |
||||||
|
{ |
||||||
|
Remove(itemsToRemove); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Remove items based on provided keys |
||||||
|
/// </summary> |
||||||
|
/// <param name="keys">identified of the in-memory storage item</param> |
||||||
|
public void Remove(IEnumerable<string> keys) |
||||||
|
{ |
||||||
|
foreach (var key in keys) |
||||||
|
{ |
||||||
|
if (string.IsNullOrWhiteSpace(key)) |
||||||
|
{ |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
_inMemoryStorage.TryRemove(key, out _); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Add new item to in-memory storage |
||||||
|
/// </summary> |
||||||
|
/// <param name="item">item to be stored</param> |
||||||
|
public void SetItem(InMemoryStorageItem<T> item) |
||||||
|
{ |
||||||
|
if (MaxItemCount == 0) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
_inMemoryStorage[item.Id] = item; |
||||||
|
|
||||||
|
// ensure max limit is maintained. trim older entries first |
||||||
|
if (_inMemoryStorage.Count > MaxItemCount) |
||||||
|
{ |
||||||
|
var itemsToRemove = _inMemoryStorage |
||||||
|
.OrderBy(kvp => kvp.Value.Created) |
||||||
|
.Take(_inMemoryStorage.Count - MaxItemCount) |
||||||
|
.Select(kvp => kvp.Key); |
||||||
|
Remove(itemsToRemove); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Get item from in-memory storage as long as it has not ex |
||||||
|
/// </summary> |
||||||
|
/// <param name="id">id of the in-memory storage item</param> |
||||||
|
/// <param name="duration">timespan denoting expiration</param> |
||||||
|
/// <returns>Valid item if not out of date or return null if out of date or item does not exist</returns> |
||||||
|
public InMemoryStorageItem<T>? GetItem(string id, TimeSpan duration) |
||||||
|
{ |
||||||
|
if (!_inMemoryStorage.TryGetValue(id, out var tempItem)) |
||||||
|
{ |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
var expirationDate = DateTime.Now.Subtract(duration); |
||||||
|
|
||||||
|
if (tempItem.LastUpdated > expirationDate) |
||||||
|
{ |
||||||
|
return tempItem; |
||||||
|
} |
||||||
|
|
||||||
|
_inMemoryStorage.TryRemove(id, out _); |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
private void EnsureStorageBounds(int maxCount) |
||||||
|
{ |
||||||
|
if (_inMemoryStorage.Count == 0) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (maxCount == 0) |
||||||
|
{ |
||||||
|
_inMemoryStorage.Clear(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (_inMemoryStorage.Count > maxCount) |
||||||
|
{ |
||||||
|
Remove(_inMemoryStorage.Keys.Take(_inMemoryStorage.Count - maxCount)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,49 @@ |
|||||||
|
// Licensed to the .NET Foundation under one or more agreements. |
||||||
|
// The .NET Foundation licenses this file to you under the MIT license. |
||||||
|
// See the LICENSE file in the project root for more information. |
||||||
|
|
||||||
|
using System; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Generic InMemoryStorageItem holds items for InMemoryStorage. |
||||||
|
/// </summary> |
||||||
|
/// <typeparam name="T">Type is set by consuming cache</typeparam> |
||||||
|
public class InMemoryStorageItem<T> |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Gets the item identifier |
||||||
|
/// </summary> |
||||||
|
public string Id { get; private set; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets the item created timestamp. |
||||||
|
/// </summary> |
||||||
|
public DateTime Created { get; private set; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets the item last updated timestamp. |
||||||
|
/// </summary> |
||||||
|
public DateTime LastUpdated { get; private set; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Gets the item being stored. |
||||||
|
/// </summary> |
||||||
|
public T Item { get; private set; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Initializes a new instance of the <see cref="InMemoryStorageItem{T}"/> class. |
||||||
|
/// Constructor for InMemoryStorageItem |
||||||
|
/// </summary> |
||||||
|
/// <param name="id">uniquely identifies the item</param> |
||||||
|
/// <param name="lastUpdated">last updated timestamp</param> |
||||||
|
/// <param name="item">the item being stored</param> |
||||||
|
public InMemoryStorageItem(string id, DateTime lastUpdated, T item) |
||||||
|
{ |
||||||
|
Id = id; |
||||||
|
LastUpdated = lastUpdated; |
||||||
|
Item = item; |
||||||
|
Created = DateTime.Now; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,21 @@ |
|||||||
|
MIT License |
||||||
|
|
||||||
|
Copyright (c) 2023 AvaloniaUI |
||||||
|
|
||||||
|
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. |
@ -0,0 +1,48 @@ |
|||||||
|
<ResourceDictionary |
||||||
|
xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:vendorLabs="clr-namespace:StabilityMatrix.Avalonia.Controls.VendorLabs"> |
||||||
|
<Design.PreviewWith> |
||||||
|
<Border Width="200" |
||||||
|
Height="200"> |
||||||
|
|
||||||
|
</Border> |
||||||
|
</Design.PreviewWith> |
||||||
|
|
||||||
|
<ControlTheme x:Key="{x:Type vendorLabs:BetterAsyncImage}" |
||||||
|
TargetType="vendorLabs:BetterAsyncImage"> |
||||||
|
<Setter Property="Background" Value="Transparent" /> |
||||||
|
<Setter Property="IsTabStop" Value="False" /> |
||||||
|
<Setter Property="Template"> |
||||||
|
<ControlTemplate> |
||||||
|
<Border Margin="0" |
||||||
|
Padding="0" |
||||||
|
ClipToBounds="True" |
||||||
|
Background="{TemplateBinding Background}" |
||||||
|
BorderBrush="{TemplateBinding BorderBrush}" |
||||||
|
BorderThickness="{TemplateBinding BorderThickness}" |
||||||
|
CornerRadius="{TemplateBinding CornerRadius}"> |
||||||
|
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> |
||||||
|
<Image Name="PART_PlaceholderImage" |
||||||
|
HorizontalAlignment="{TemplateBinding HorizontalAlignment}" |
||||||
|
VerticalAlignment="{TemplateBinding VerticalAlignment}" |
||||||
|
Source="{TemplateBinding PlaceholderSource}" |
||||||
|
Stretch="{TemplateBinding PlaceholderStretch}"/> |
||||||
|
<Image Name="PART_Image" |
||||||
|
HorizontalAlignment="{TemplateBinding HorizontalAlignment}" |
||||||
|
VerticalAlignment="{TemplateBinding VerticalAlignment}" |
||||||
|
Stretch="{TemplateBinding Stretch}"/> |
||||||
|
</Grid> |
||||||
|
</Border> |
||||||
|
</ControlTemplate> |
||||||
|
</Setter> |
||||||
|
<Style Selector="^[State=Failed] /template/ Image#PART_Image"> |
||||||
|
<Setter Property="Opacity" |
||||||
|
Value="0.0" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="^[State=Failed] /template/ Image#PART_PlaceholderImage"> |
||||||
|
<Setter Property="Opacity" |
||||||
|
Value="1.0" /> |
||||||
|
</Style> |
||||||
|
</ControlTheme> |
||||||
|
</ResourceDictionary> |
@ -0,0 +1,122 @@ |
|||||||
|
<Styles xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="using:StabilityMatrix.Avalonia.Controls" |
||||||
|
x:DataType="video:SvdImgToVidConditioningViewModel" |
||||||
|
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||||
|
xmlns:video="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Inference.Video" |
||||||
|
xmlns:controls1="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" |
||||||
|
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"> |
||||||
|
<Design.PreviewWith> |
||||||
|
<Grid MinWidth="400"> |
||||||
|
<controls:VideoGenerationSettingsCard DataContext="{x:Static mocks:DesignData.SvdImgToVidConditioningViewModel}" /> |
||||||
|
</Grid> |
||||||
|
</Design.PreviewWith> |
||||||
|
|
||||||
|
<Style Selector="controls|VideoGenerationSettingsCard"> |
||||||
|
<!-- Set Defaults --> |
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" /> |
||||||
|
<Setter Property="Template"> |
||||||
|
<ControlTemplate> |
||||||
|
<controls:Card Padding="8" HorizontalAlignment="{TemplateBinding HorizontalAlignment}"> |
||||||
|
<Grid Margin="4" RowDefinitions="Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto, *"> |
||||||
|
<TextBlock |
||||||
|
Grid.Row="0" |
||||||
|
Grid.Column="0" |
||||||
|
Margin="0,0,8,0" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{x:Static lang:Resources.Label_Frames}" /> |
||||||
|
<controls1:NumberBox |
||||||
|
Grid.Row="0" |
||||||
|
Grid.Column="1" |
||||||
|
SelectionHighlightColor="Transparent" |
||||||
|
Value="{Binding NumFrames}" |
||||||
|
Margin="8,0,0,0" |
||||||
|
SimpleNumberFormat="F0" |
||||||
|
SmallChange="1" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
SpinButtonPlacementMode="Inline"/> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
Grid.Row="1" |
||||||
|
Grid.Column="0" |
||||||
|
Margin="0,8,8,0" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{x:Static lang:Resources.Label_Fps}" /> |
||||||
|
<controls1:NumberBox |
||||||
|
Grid.Row="1" |
||||||
|
Grid.Column="1" |
||||||
|
SelectionHighlightColor="Transparent" |
||||||
|
Value="{Binding Fps}" |
||||||
|
Margin="8,8,0,0" |
||||||
|
SimpleNumberFormat="F0" |
||||||
|
SmallChange="1" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
SpinButtonPlacementMode="Inline"/> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
Grid.Row="2" |
||||||
|
Grid.Column="0" |
||||||
|
Margin="0,8,8,0" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{x:Static lang:Resources.Label_MinCfg}" /> |
||||||
|
<controls1:NumberBox |
||||||
|
Margin="8,8,0,0" |
||||||
|
Grid.Row="2" |
||||||
|
Grid.Column="1" |
||||||
|
SelectionHighlightColor="Transparent" |
||||||
|
Value="{Binding MinCfg}" |
||||||
|
SimpleNumberFormat="F0" |
||||||
|
SmallChange="1" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
SpinButtonPlacementMode="Inline"/> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
Margin="0,8,8,0" |
||||||
|
Grid.Row="3" |
||||||
|
Grid.Column="0" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{x:Static lang:Resources.Label_MotionBucketId}" /> |
||||||
|
<controls1:NumberBox |
||||||
|
Margin="8,8,0,0" |
||||||
|
Grid.Row="3" |
||||||
|
Grid.Column="1" |
||||||
|
SelectionHighlightColor="Transparent" |
||||||
|
Value="{Binding MotionBucketId}" |
||||||
|
SimpleNumberFormat="F0" |
||||||
|
SmallChange="1" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
SpinButtonPlacementMode="Inline"/> |
||||||
|
|
||||||
|
<StackPanel Grid.Column="0" |
||||||
|
Grid.ColumnSpan="2" |
||||||
|
Grid.Row="4" |
||||||
|
Margin="0,16,0,0"> |
||||||
|
<Grid ColumnDefinitions="*,Auto"> |
||||||
|
<TextBlock |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{x:Static lang:Resources.Label_AugmentationLevel}" /> |
||||||
|
<controls1:NumberBox |
||||||
|
Grid.Column="1" |
||||||
|
Margin="4,0,0,0" |
||||||
|
ValidationMode="InvalidInputOverwritten" |
||||||
|
SmallChange="0.01" |
||||||
|
SimpleNumberFormat="F2" |
||||||
|
Value="{Binding AugmentationLevel}" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
MinWidth="100" |
||||||
|
SpinButtonPlacementMode="Compact"/> |
||||||
|
</Grid> |
||||||
|
<Slider |
||||||
|
Minimum="0" |
||||||
|
Maximum="10" |
||||||
|
Value="{Binding AugmentationLevel}" |
||||||
|
TickFrequency="1" |
||||||
|
Margin="0,0,0,-4" |
||||||
|
TickPlacement="BottomRight"/> |
||||||
|
</StackPanel> |
||||||
|
</Grid> |
||||||
|
</controls:Card> |
||||||
|
</ControlTemplate> |
||||||
|
</Setter> |
||||||
|
</Style> |
||||||
|
</Styles> |
@ -0,0 +1,7 @@ |
|||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
[Transient] |
||||||
|
public class VideoGenerationSettingsCard : TemplatedControl { } |
@ -0,0 +1,98 @@ |
|||||||
|
<Styles xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="using:StabilityMatrix.Avalonia.Controls" |
||||||
|
x:DataType="video:VideoOutputSettingsCardViewModel" |
||||||
|
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||||
|
xmlns:video="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Inference.Video" |
||||||
|
xmlns:controls1="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" |
||||||
|
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"> |
||||||
|
<Design.PreviewWith> |
||||||
|
<Grid MinWidth="400"> |
||||||
|
<controls:VideoOutputSettingsCard |
||||||
|
DataContext="{x:Static mocks:DesignData.SvdImgToVidConditioningViewModel}" /> |
||||||
|
</Grid> |
||||||
|
</Design.PreviewWith> |
||||||
|
|
||||||
|
<Style Selector="controls|VideoOutputSettingsCard"> |
||||||
|
<!-- Set Defaults --> |
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" /> |
||||||
|
<Setter Property="Template"> |
||||||
|
<ControlTemplate> |
||||||
|
<controls:Card Padding="8" HorizontalAlignment="{TemplateBinding HorizontalAlignment}"> |
||||||
|
<Grid Margin="4" RowDefinitions="Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto, *"> |
||||||
|
<TextBlock |
||||||
|
Grid.Row="0" |
||||||
|
Grid.Column="0" |
||||||
|
Grid.ColumnSpan="2" |
||||||
|
Text="Video Output Settings" |
||||||
|
FontSize="16" |
||||||
|
FontWeight="DemiBold" |
||||||
|
Margin="0,0,0,16" |
||||||
|
/> |
||||||
|
<TextBlock |
||||||
|
Grid.Row="1" |
||||||
|
Grid.Column="0" |
||||||
|
Margin="0,0,8,0" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{x:Static lang:Resources.Label_Fps}" /> |
||||||
|
<controls1:NumberBox |
||||||
|
Grid.Row="1" |
||||||
|
Grid.Column="1" |
||||||
|
SelectionHighlightColor="Transparent" |
||||||
|
Value="{Binding Fps}" |
||||||
|
Margin="8,0,0,0" |
||||||
|
SimpleNumberFormat="F0" |
||||||
|
SmallChange="1" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
SpinButtonPlacementMode="Inline" /> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
Grid.Row="2" |
||||||
|
Grid.Column="0" |
||||||
|
Margin="0,8,8,0" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{x:Static lang:Resources.Label_Lossless}" /> |
||||||
|
<CheckBox |
||||||
|
Grid.Row="2" |
||||||
|
Grid.Column="1" |
||||||
|
IsChecked="{Binding Lossless}" |
||||||
|
Margin="8,8,0,0" |
||||||
|
HorizontalAlignment="Stretch" /> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
Grid.Row="3" |
||||||
|
Grid.Column="0" |
||||||
|
Margin="0,8,8,0" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{x:Static lang:Resources.Label_VideoQuality}" /> |
||||||
|
<controls1:NumberBox |
||||||
|
Margin="8,8,0,0" |
||||||
|
Grid.Row="3" |
||||||
|
Grid.Column="1" |
||||||
|
SelectionHighlightColor="Transparent" |
||||||
|
Value="{Binding Quality}" |
||||||
|
SimpleNumberFormat="F0" |
||||||
|
SmallChange="1" |
||||||
|
Maximum="100" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
SpinButtonPlacementMode="Inline" /> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
Margin="0,8,8,0" |
||||||
|
Grid.Row="4" |
||||||
|
Grid.Column="0" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{x:Static lang:Resources.Label_VideoOutputMethod}" /> |
||||||
|
<ComboBox |
||||||
|
Grid.Row="4" |
||||||
|
Grid.Column="1" |
||||||
|
Margin="8,8,0,0" |
||||||
|
MinWidth="100" |
||||||
|
ItemsSource="{Binding AvailableMethods}" |
||||||
|
SelectedIndex="{Binding SelectedMethod}" /> |
||||||
|
</Grid> |
||||||
|
</controls:Card> |
||||||
|
</ControlTemplate> |
||||||
|
</Setter> |
||||||
|
</Style> |
||||||
|
</Styles> |
@ -0,0 +1,7 @@ |
|||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
[Transient] |
||||||
|
public class VideoOutputSettingsCard : TemplatedControl { } |
@ -0,0 +1,40 @@ |
|||||||
|
using System; |
||||||
|
using System.Globalization; |
||||||
|
using System.Linq; |
||||||
|
using System.Reflection; |
||||||
|
using Avalonia.Data.Converters; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Converters; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Converts an enum value to an attribute |
||||||
|
/// </summary> |
||||||
|
/// <typeparam name="TAttribute">Type of attribute</typeparam> |
||||||
|
public class EnumAttributeConverter<TAttribute>(Func<TAttribute, object?>? accessor = null) : IValueConverter |
||||||
|
where TAttribute : Attribute |
||||||
|
{ |
||||||
|
/// <inheritdoc /> |
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
if (value is null) |
||||||
|
return null; |
||||||
|
|
||||||
|
if (value is not Enum @enum) |
||||||
|
throw new ArgumentException("Value must be an enum"); |
||||||
|
|
||||||
|
var field = @enum.GetType().GetField(@enum.ToString()); |
||||||
|
if (field is null) |
||||||
|
throw new ArgumentException("Value must be an enum"); |
||||||
|
|
||||||
|
if (field.GetCustomAttributes<TAttribute>().FirstOrDefault() is not { } attribute) |
||||||
|
throw new ArgumentException($"Enum value {@enum} does not have attribute {typeof(TAttribute)}"); |
||||||
|
|
||||||
|
return accessor is not null ? accessor(attribute) : attribute; |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
throw new NotSupportedException(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,13 @@ |
|||||||
|
using System.ComponentModel.DataAnnotations; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Converters; |
||||||
|
|
||||||
|
internal static class EnumAttributeConverters |
||||||
|
{ |
||||||
|
public static EnumAttributeConverter<DisplayAttribute> Display => new(); |
||||||
|
|
||||||
|
public static EnumAttributeConverter<DisplayAttribute> DisplayName => new(attribute => attribute.Name); |
||||||
|
|
||||||
|
public static EnumAttributeConverter<DisplayAttribute> DisplayDescription => |
||||||
|
new(attribute => attribute.Description); |
||||||
|
} |
@ -0,0 +1,37 @@ |
|||||||
|
using System; |
||||||
|
using System.Globalization; |
||||||
|
using Avalonia.Data.Converters; |
||||||
|
using StabilityMatrix.Core.Extensions; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Converters; |
||||||
|
|
||||||
|
public class FileUriConverter : IValueConverter |
||||||
|
{ |
||||||
|
/// <inheritdoc /> |
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
if (targetType != typeof(Uri)) |
||||||
|
{ |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return value switch |
||||||
|
{ |
||||||
|
string str when str.StartsWith("avares://") => new Uri(str), |
||||||
|
string str => new Uri("file://" + str), |
||||||
|
IFormattable formattable => new Uri("file://" + formattable.ToString(null, culture)), |
||||||
|
_ => null |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
if (targetType == typeof(string) && value is Uri uri) |
||||||
|
{ |
||||||
|
return uri.ToString().StripStart("file://"); |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,53 @@ |
|||||||
|
using System; |
||||||
|
using System.Globalization; |
||||||
|
using System.Windows.Input; |
||||||
|
using Avalonia.Data.Converters; |
||||||
|
using PropertyModels.ComponentModel; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Converters; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Converts an object's named <see cref="Func{TResult}"/> to a <see cref="ICommand"/>. |
||||||
|
/// </summary> |
||||||
|
public class FuncCommandConverter : IValueConverter |
||||||
|
{ |
||||||
|
/// <inheritdoc /> |
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
if (value is null || parameter is null) |
||||||
|
{ |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Parameter is the name of the Func<T> to convert. |
||||||
|
if (parameter is not string funcName) |
||||||
|
{ |
||||||
|
// ReSharper disable once LocalizableElement |
||||||
|
throw new ArgumentException("Parameter must be a string.", nameof(parameter)); |
||||||
|
} |
||||||
|
|
||||||
|
// Find the Func<T> on the object. |
||||||
|
if (value.GetType().GetMethod(funcName) is not { } methodInfo) |
||||||
|
{ |
||||||
|
// ReSharper disable once LocalizableElement |
||||||
|
throw new ArgumentException( |
||||||
|
$"Method {funcName} not found on {value.GetType().Name}.", |
||||||
|
nameof(parameter) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// Create a delegate from the method info. |
||||||
|
var func = (Action)methodInfo.CreateDelegate(typeof(Action), value); |
||||||
|
|
||||||
|
// Create ICommand |
||||||
|
var command = ReactiveCommand.Create(func); |
||||||
|
|
||||||
|
return command; |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
throw new NotSupportedException(); |
||||||
|
} |
||||||
|
} |
@ -1,9 +1,22 @@ |
|||||||
using StabilityMatrix.Core.Services; |
using System.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
namespace StabilityMatrix.Avalonia.DesignData; |
namespace StabilityMatrix.Avalonia.DesignData; |
||||||
|
|
||||||
public class MockSettingsManager : SettingsManager |
public class MockSettingsManager : SettingsManager |
||||||
{ |
{ |
||||||
protected override void LoadSettings() {} |
protected override void LoadSettings(CancellationToken cancellationToken = default) { } |
||||||
protected override void SaveSettings() {} |
|
||||||
|
protected override Task LoadSettingsAsync(CancellationToken cancellationToken = default) |
||||||
|
{ |
||||||
|
return Task.CompletedTask; |
||||||
|
} |
||||||
|
|
||||||
|
protected override void SaveSettings(CancellationToken cancellationToken = default) { } |
||||||
|
|
||||||
|
protected override Task SaveSettingsAsync(CancellationToken cancellationToken = default) |
||||||
|
{ |
||||||
|
return Task.CompletedTask; |
||||||
|
} |
||||||
} |
} |
||||||
|
@ -0,0 +1,20 @@ |
|||||||
|
using System; |
||||||
|
using Avalonia.Controls.Notifications; |
||||||
|
using StabilityMatrix.Core.Models.Settings; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Extensions; |
||||||
|
|
||||||
|
public static class NotificationLevelExtensions |
||||||
|
{ |
||||||
|
public static NotificationType ToNotificationType(this NotificationLevel level) |
||||||
|
{ |
||||||
|
return level switch |
||||||
|
{ |
||||||
|
NotificationLevel.Information => NotificationType.Information, |
||||||
|
NotificationLevel.Success => NotificationType.Success, |
||||||
|
NotificationLevel.Warning => NotificationType.Warning, |
||||||
|
NotificationLevel.Error => NotificationType.Error, |
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(level), level, null) |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,54 @@ |
|||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using DesktopNotifications; |
||||||
|
using NLog; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Core.Models.PackageModification; |
||||||
|
using StabilityMatrix.Core.Models.Settings; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Extensions; |
||||||
|
|
||||||
|
public static class NotificationServiceExtensions |
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
|
||||||
|
public static void OnPackageInstallCompleted( |
||||||
|
this INotificationService notificationService, |
||||||
|
IPackageModificationRunner runner |
||||||
|
) |
||||||
|
{ |
||||||
|
OnPackageInstallCompletedAsync(notificationService, runner) |
||||||
|
.SafeFireAndForget(ex => Logger.Error(ex, "Error Showing Notification")); |
||||||
|
} |
||||||
|
|
||||||
|
private static async Task OnPackageInstallCompletedAsync( |
||||||
|
this INotificationService notificationService, |
||||||
|
IPackageModificationRunner runner |
||||||
|
) |
||||||
|
{ |
||||||
|
if (runner.Failed) |
||||||
|
{ |
||||||
|
Logger.Error(runner.Exception, "Error Installing Package"); |
||||||
|
|
||||||
|
await notificationService.ShowAsync( |
||||||
|
NotificationKey.Package_Install_Failed, |
||||||
|
new Notification |
||||||
|
{ |
||||||
|
Title = runner.ModificationFailedTitle, |
||||||
|
Body = runner.ModificationFailedMessage |
||||||
|
} |
||||||
|
); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
await notificationService.ShowAsync( |
||||||
|
NotificationKey.Package_Install_Completed, |
||||||
|
new Notification |
||||||
|
{ |
||||||
|
Title = runner.ModificationCompleteTitle, |
||||||
|
Body = runner.ModificationCompleteMessage |
||||||
|
} |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue