diff --git a/Cargo.lock b/Cargo.lock index ead7189..0cadccd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,7 +5,7 @@ version = 4 [[package]] name = "ab_glyph_rasterizer" version = "0.1.8" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "adler" @@ -26,21 +26,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", - "cipher 0.4.4", + "cipher", "cpufeatures 0.2.17", ] -[[package]] -name = "aes" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" -dependencies = [ - "cipher 0.5.2", - "cpubits", - "cpufeatures 0.3.0", -] - [[package]] name = "ahash" version = "0.8.12" @@ -166,14 +155,14 @@ dependencies = [ [[package]] name = "arrayvec" version = "0.7.6" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "ash" version = "0.38.0+1.3.281" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "libloading 0.8.9 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "libloading 0.8.9 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] @@ -385,16 +374,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "atoi_simd" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cdb3708a128e559a30fb830e8a77a5022ee6902806925c216658652b452a44" -dependencies = [ - "debug_unsafe", - "rustversion", -] - [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -481,9 +460,9 @@ dependencies = [ [[package]] name = "bit-set" version = "0.8.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "bit-vec 0.8.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "bit-vec 0.8.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] @@ -495,12 +474,12 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bit-vec" version = "0.8.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "bitflags" version = "2.10.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "bitflags" @@ -517,16 +496,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", - "zeroize", -] - [[package]] name = "block-padding" version = "0.3.3" @@ -599,7 +568,7 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" version = "1.25.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "bytemuck" @@ -630,7 +599,7 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "byteorder" version = "1.5.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "byteorder-lite" @@ -644,39 +613,13 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "bzip2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" -dependencies = [ - "libbz2-rs-sys", -] - -[[package]] -name = "calamine" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fa68281b1a76b54a62156474adb06bb380a67e07dd60656e3217152b42183f3" -dependencies = [ - "atoi_simd", - "byteorder 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)", - "codepage", - "encoding_rs", - "fast-float2", - "log 0.4.33", - "quick-xml", - "serde", - "zip", -] - [[package]] name = "cbc" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher 0.4.4", + "cipher", ] [[package]] @@ -706,12 +649,12 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg-if" version = "1.0.4" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "cfg_aliases" version = "0.2.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "cfg_aliases" @@ -750,18 +693,8 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.7", - "inout 0.1.4", -] - -[[package]] -name = "cipher" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" -dependencies = [ - "crypto-common 0.2.2", - "inout 0.2.2", + "crypto-common", + "inout", ] [[package]] @@ -813,19 +746,13 @@ dependencies = [ "cc", ] -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - [[package]] name = "cms" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" dependencies = [ - "const-oid 0.9.6", + "const-oid", "der", "spki", "x509-cert", @@ -840,15 +767,6 @@ dependencies = [ "thiserror 2.0.20", ] -[[package]] -name = "codepage" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" -dependencies = [ - "encoding_rs", -] - [[package]] name = "colorchoice" version = "1.0.5" @@ -880,18 +798,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - [[package]] name = "convert_case" version = "0.6.0" @@ -917,12 +823,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -984,7 +884,7 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" version = "0.2.4" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "crypto-bigint" @@ -1008,15 +908,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - [[package]] name = "csv" version = "1.4.0" @@ -1059,15 +950,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1077,7 +959,7 @@ dependencies = [ "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest 0.10.7", + "digest", "fiat-crypto", "rustc_version", "subtle", @@ -1095,25 +977,13 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "debug_unsafe" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" - -[[package]] -name = "deflate64" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" - [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid 0.9.6", + "const-oid", "der_derive", "flagset", "pem-rfc7468", @@ -1143,25 +1013,12 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.7", + "block-buffer", + "const-oid", + "crypto-common", "subtle", ] -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid 0.10.2", - "crypto-common 0.2.2", - "ctutils", - "zeroize", -] - [[package]] name = "dirs-sys" version = "0.5.0" @@ -1205,25 +1062,10 @@ dependencies = [ "serde_json", ] -[[package]] -name = "doc-ui" -version = "0.1.0" -dependencies = [ - "doc-engine", - "makepad-test", - "makepad-widgets", - "nigig-core", - "quick-xml", - "robius-file-picker", - "serde", - "serde_json", - "zip", -] - [[package]] name = "downcast-rs" version = "1.2.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "dunce" @@ -1238,7 +1080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest 0.10.7", + "digest", "elliptic-curve", "rfc6979", "signature", @@ -1264,16 +1106,16 @@ dependencies = [ "curve25519-dalek", "ed25519", "serde", - "sha2 0.10.9", + "sha2", "subtle", "zeroize", ] [[package]] name = "either" -version = "1.18.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "elliptic-curve" @@ -1283,7 +1125,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest 0.10.7", + "digest", "ff", "generic-array", "group", @@ -1368,7 +1210,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "equivalent" version = "1.0.2" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "errno" @@ -1439,12 +1281,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" -[[package]] -name = "fast-float2" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" - [[package]] name = "fastrand" version = "2.5.0" @@ -1496,7 +1332,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide 0.8.9", - "zlib-rs", ] [[package]] @@ -1514,7 +1349,7 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "foldhash" version = "0.2.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "foreign-types" @@ -1650,9 +1485,9 @@ dependencies = [ [[package]] name = "fxhash" version = "0.2.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "byteorder 1.5.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "byteorder 1.5.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] @@ -1761,9 +1596,9 @@ dependencies = [ [[package]] name = "hashbrown" version = "0.16.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "foldhash 0.2.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "foldhash 0.2.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] @@ -1816,7 +1651,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hexf-parse" version = "0.2.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "hilog-sys" @@ -1830,7 +1665,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", + "hmac", ] [[package]] @@ -1839,16 +1674,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", + "digest", ] [[package]] @@ -1907,15 +1733,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hybrid-array" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" -dependencies = [ - "typenum", -] - [[package]] name = "hyper" version = "1.11.0" @@ -1996,7 +1813,7 @@ dependencies = [ [[package]] name = "i_float" version = "3.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "i_key_sort" @@ -2007,7 +1824,7 @@ checksum = "7c6c58d0c60705e66264ce0f788a69a2f21472aeb8188559e7c8c619dbdc10fa" [[package]] name = "i_key_sort" version = "0.11.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "i_overlay" @@ -2024,12 +1841,12 @@ dependencies = [ [[package]] name = "i_overlay" version = "7.0.3" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "i_float 3.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", - "i_key_sort 0.11.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", - "i_shape 3.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", - "i_tree 0.19.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "i_float 3.0.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", + "i_key_sort 0.11.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", + "i_shape 3.0.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", + "i_tree 0.19.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] @@ -2053,9 +1870,9 @@ dependencies = [ [[package]] name = "i_shape" version = "3.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "i_float 3.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "i_float 3.0.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] @@ -2067,7 +1884,7 @@ checksum = "1e9d4a992a9fe83130f41ceacceac3bb116a4355dfc9c8d6ecea4f1e4b4c6caf" [[package]] name = "i_tree" version = "0.19.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "iana-time-zone" @@ -2163,9 +1980,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -2228,10 +2045,10 @@ checksum = "c8b35f3ad95576ac81603375dfe47a0450b70a368aa34d2b6e5bb0a0d7f02428" [[package]] name = "indexmap" version = "2.13.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "equivalent 1.0.2 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", - "hashbrown 0.16.1 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "equivalent 1.0.2 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", + "hashbrown 0.16.1 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] @@ -2254,15 +2071,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "inout" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" -dependencies = [ - "hybrid-array", -] - [[package]] name = "ipnet" version = "2.12.1" @@ -2404,12 +2212,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "libbz2-rs-sys" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" - [[package]] name = "libc" version = "0.2.189" @@ -2429,10 +2231,10 @@ dependencies = [ [[package]] name = "libloading" version = "0.8.9" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "cfg-if 1.0.4 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", - "windows-link 0.2.1 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "cfg-if 1.0.4 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", + "windows-link 0.2.1 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] @@ -2491,7 +2293,7 @@ dependencies = [ [[package]] name = "log" version = "0.4.29" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "log" @@ -2522,22 +2324,13 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lzma-rust2" -version = "0.16.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" -dependencies = [ - "sha2 0.11.0", -] - [[package]] name = "makepad-ai" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-live-id 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", - "makepad-micro-serde 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-live-id", + "makepad-micro-serde", "makepad-widgets", ] @@ -2553,7 +2346,7 @@ dependencies = [ [[package]] name = "makepad-apple-sys" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-objc-sys", ] @@ -2561,22 +2354,22 @@ dependencies = [ [[package]] name = "makepad-base64" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "makepad-box3d" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "makepad-byteorder-lite" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "makepad-code-editor" version = "2.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-widgets", ] @@ -2584,7 +2377,7 @@ dependencies = [ [[package]] name = "makepad-csg" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-csg-boolean", "makepad-csg-math", @@ -2596,7 +2389,7 @@ dependencies = [ [[package]] name = "makepad-csg-boolean" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-csg-exact", "makepad-csg-math", @@ -2606,7 +2399,7 @@ dependencies = [ [[package]] name = "makepad-csg-exact" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-csg-math", ] @@ -2614,12 +2407,12 @@ dependencies = [ [[package]] name = "makepad-csg-math" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "makepad-csg-mesh" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-csg-math", ] @@ -2627,7 +2420,7 @@ dependencies = [ [[package]] name = "makepad-csg-primitives" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-csg-math", "makepad-csg-mesh", @@ -2636,7 +2429,7 @@ dependencies = [ [[package]] name = "makepad-csg-sdf" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-csg-math", "makepad-csg-mesh", @@ -2645,29 +2438,29 @@ dependencies = [ [[package]] name = "makepad-derive-wasm-bridge" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-micro-proc-macro 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-micro-proc-macro", ] [[package]] name = "makepad-derive-widget" version = "2.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-live-id 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", - "makepad-micro-proc-macro 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-live-id", + "makepad-micro-proc-macro", ] [[package]] name = "makepad-draw" version = "2.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "ab_glyph_rasterizer", "fxhash", "makepad-gif", - "makepad-live-id 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-live-id", "makepad-math", "makepad-platform", "makepad-svg", @@ -2687,43 +2480,43 @@ dependencies = [ [[package]] name = "makepad-error-log" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-micro-serde 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-micro-serde", ] [[package]] name = "makepad-fast-inflate" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "makepad-filesystem-watcher" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "makepad-futures" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "makepad-futures-legacy" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "makepad-gif" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "weezl 0.1.12 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "weezl 0.1.12 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] name = "makepad-git" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-fast-inflate", ] @@ -2731,19 +2524,19 @@ dependencies = [ [[package]] name = "makepad-gltf" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-base64", "makepad-math", - "makepad-micro-serde 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-micro-serde", ] [[package]] name = "makepad-half" version = "2.7.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "cfg-if 1.0.4 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "cfg-if 1.0.4 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", "crunchy", "num-traits 0.2.20", "zerocopy 0.8.39", @@ -2752,9 +2545,9 @@ dependencies = [ [[package]] name = "makepad-html" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-live-id 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-live-id", ] [[package]] @@ -2766,7 +2559,7 @@ checksum = "9775cbec5fa0647500c3e5de7c850280a88335d1d2d770e5aa2332b801ba7064" [[package]] name = "makepad-latex-math" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "ttf-parser 0.24.1", ] @@ -2774,40 +2567,24 @@ dependencies = [ [[package]] name = "makepad-live-id" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-live-id-macros 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-live-id-macros", "serde", ] -[[package]] -name = "makepad-live-id" -version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" -dependencies = [ - "makepad-live-id-macros 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", -] - -[[package]] -name = "makepad-live-id-macros" -version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" -dependencies = [ - "makepad-micro-proc-macro 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", -] - [[package]] name = "makepad-live-id-macros" version = "1.0.0" source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-micro-proc-macro 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", + "makepad-micro-proc-macro", ] [[package]] name = "makepad-live-reload-core" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-filesystem-watcher", ] @@ -2815,60 +2592,37 @@ dependencies = [ [[package]] name = "makepad-lz4" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "makepad-math" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-micro-serde 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-micro-serde", ] [[package]] name = "makepad-mbtile-reader" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "brotli", "makepad-fast-inflate", - "makepad-sqlite", ] -[[package]] -name = "makepad-micro-proc-macro" -version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" - [[package]] name = "makepad-micro-proc-macro" version = "1.0.0" source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" -[[package]] -name = "makepad-micro-serde" -version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" -dependencies = [ - "makepad-live-id 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", - "makepad-micro-serde-derive 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", -] - [[package]] name = "makepad-micro-serde" version = "1.0.0" source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-live-id 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", - "makepad-micro-serde-derive 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", -] - -[[package]] -name = "makepad-micro-serde-derive" -version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" -dependencies = [ - "makepad-micro-proc-macro 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-live-id", + "makepad-micro-serde-derive", ] [[package]] @@ -2876,19 +2630,19 @@ name = "makepad-micro-serde-derive" version = "1.0.0" source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-micro-proc-macro 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", + "makepad-micro-proc-macro", ] [[package]] name = "makepad-network" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-apple-sys", "makepad-error-log", "makepad-futures-legacy", - "makepad-live-id 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", - "makepad-micro-serde 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-live-id", + "makepad-micro-serde", "makepad-script", "windows 0.62.2", ] @@ -2896,12 +2650,12 @@ dependencies = [ [[package]] name = "makepad-objc-sys" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "makepad-platform" version = "2.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "ash", "bitflags 2.10.0", @@ -2920,7 +2674,6 @@ dependencies = [ "makepad-shared-bytes", "makepad-studio-protocol", "makepad-tsdf", - "makepad-video", "makepad-wasm-bridge", "makepad-zune-png", "naga", @@ -2932,28 +2685,28 @@ dependencies = [ "wayland-egl", "wayland-protocols", "windows 0.62.2", - "windows-core 0.62.2 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "windows-core 0.62.2 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", "windows-targets 0.52.6", ] [[package]] name = "makepad-rabin-karp" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "makepad-regex" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "makepad-script" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-error-log", "makepad-html", - "makepad-live-id 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-live-id", "makepad-math", "makepad-regex", "makepad-script-derive", @@ -2963,15 +2716,15 @@ dependencies = [ [[package]] name = "makepad-script-derive" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-micro-proc-macro 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-micro-proc-macro", ] [[package]] name = "makepad-script-std" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-network", "makepad-script", @@ -2980,32 +2733,27 @@ dependencies = [ [[package]] name = "makepad-shared-bytes" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "makepad-splat" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-micro-serde 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-micro-serde", "makepad-webp", "makepad-zip-file", ] -[[package]] -name = "makepad-sqlite" -version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" - [[package]] name = "makepad-studio-hub" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-filesystem-watcher", "makepad-git", - "makepad-live-id 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", - "makepad-micro-serde 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-live-id", + "makepad-micro-serde", "makepad-network", "makepad-rabin-karp", "makepad-regex", @@ -3017,28 +2765,28 @@ dependencies = [ [[package]] name = "makepad-studio-protocol" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "bitflags 2.10.0", "makepad-error-log", - "makepad-live-id 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", - "makepad-micro-serde 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-live-id", + "makepad-micro-serde", "makepad-script", ] [[package]] name = "makepad-svg" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-html", - "makepad-live-id 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-live-id", ] [[package]] name = "makepad-terminal-core" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "windows 0.62.2", ] @@ -3046,9 +2794,9 @@ dependencies = [ [[package]] name = "makepad-test" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-micro-serde 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-micro-serde", "makepad-network", "makepad-studio-hub", "makepad-studio-protocol", @@ -3058,7 +2806,7 @@ dependencies = [ [[package]] name = "makepad-test-macros" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "proc-macro2", "quote", @@ -3068,35 +2816,25 @@ dependencies = [ [[package]] name = "makepad-tsdf" version = "0.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-math", - "makepad-micro-serde 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", -] - -[[package]] -name = "makepad-video" -version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" -dependencies = [ - "makepad-apple-sys", - "makepad-objc-sys", - "windows 0.62.2", + "makepad-micro-serde", ] [[package]] name = "makepad-wasm-bridge" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-derive-wasm-bridge", - "makepad-live-id 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-live-id", ] [[package]] name = "makepad-webp" version = "0.2.4" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-byteorder-lite", ] @@ -3104,9 +2842,9 @@ dependencies = [ [[package]] name = "makepad-widgets" version = "2.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "i_overlay 7.0.3 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "i_overlay 7.0.3 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", "makepad-csg", "makepad-derive-widget", "makepad-draw", @@ -3125,7 +2863,7 @@ dependencies = [ [[package]] name = "makepad-xr" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-box3d", "makepad-gltf", @@ -3137,7 +2875,7 @@ dependencies = [ [[package]] name = "makepad-zip-file" version = "1.0.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-fast-inflate", ] @@ -3145,7 +2883,7 @@ dependencies = [ [[package]] name = "makepad-zune-bmp" version = "0.5.2" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "log 0.4.33", "makepad-zune-core", @@ -3154,7 +2892,7 @@ dependencies = [ [[package]] name = "makepad-zune-core" version = "0.5.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "log 0.4.33", ] @@ -3162,7 +2900,7 @@ dependencies = [ [[package]] name = "makepad-zune-inflate" version = "0.2.54" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "simd-adler32 0.3.9", ] @@ -3170,7 +2908,7 @@ dependencies = [ [[package]] name = "makepad-zune-jpeg" version = "0.5.15" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-zune-core", ] @@ -3178,9 +2916,8 @@ dependencies = [ [[package]] name = "makepad-zune-png" version = "0.5.2" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-fast-inflate", "makepad-zune-core", "makepad-zune-inflate", ] @@ -3188,7 +2925,7 @@ dependencies = [ [[package]] name = "makepad-zune-qoi" version = "0.5.2" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "makepad-zune-core", ] @@ -3216,7 +2953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", - "digest 0.10.7", + "digest", ] [[package]] @@ -3228,7 +2965,7 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "memchr" version = "2.7.6" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "memchr" @@ -3310,14 +3047,14 @@ dependencies = [ [[package]] name = "naga" version = "27.0.3" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "arrayvec", - "bit-set 0.8.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "bit-set 0.8.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", "bitflags 2.13.1", - "cfg-if 1.0.4 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "cfg-if 1.0.4 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", "cfg_aliases 0.2.1", - "hashbrown 0.16.1 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "hashbrown 0.16.1 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", "hexf-parse", "indexmap 2.13.0", "makepad-error-log", @@ -3446,11 +3183,10 @@ name = "nigig-build" version = "0.1.0" dependencies = [ "chrono", - "doc-ui", + "doc-engine", "makepad-ai", "makepad-base64", "makepad-code-editor", - "makepad-test", "makepad-widgets", "makepad-xr", "nigig-core", @@ -3732,13 +3468,13 @@ dependencies = [ name = "nigig-pdf-cos" version = "0.1.0" dependencies = [ - "aes 0.8.4", + "aes", "cbc", "getrandom 0.2.17", "md-5", "miniz_oxide 0.7.4", "rc4", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -3753,8 +3489,8 @@ dependencies = [ "nigig-pdf-graphics", "p256", "rsa", - "sha1 0.10.7", - "sha2 0.10.9", + "sha1", + "sha2", "x509-cert", ] @@ -4028,7 +3764,7 @@ dependencies = [ [[package]] name = "num-traits" version = "0.2.20" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "objc2" @@ -4133,7 +3869,7 @@ checksum = "8d380ab6c951261a0e44306245bc960b3b3367f099a7da7156bd1a3cacaf783c" [[package]] name = "once_cell" version = "1.21.3" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "once_cell" @@ -4224,7 +3960,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -4239,7 +3975,7 @@ name = "p2p-core" version = "0.1.0" dependencies = [ "error_set", - "makepad-micro-serde 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", + "makepad-micro-serde", ] [[package]] @@ -4253,7 +3989,6 @@ dependencies = [ "getrandom 0.2.17", "hashbrown 0.16.1 (registry+https://github.com/rust-lang/crates.io-index)", "imghdr", - "makepad-test", "makepad-widgets", "nigig-ai", "nigig-alerts", @@ -4325,16 +4060,6 @@ dependencies = [ "windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", ] -[[package]] -name = "pbkdf2" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" -dependencies = [ - "digest 0.11.3", - "hmac 0.13.0", -] - [[package]] name = "pem" version = "3.0.6" @@ -4407,7 +4132,7 @@ dependencies = [ [[package]] name = "pkg-config" version = "0.3.32" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "pkg-config" @@ -4485,12 +4210,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppmd-rust" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4561,11 +4280,11 @@ dependencies = [ [[package]] name = "pulldown-cmark" version = "0.12.2" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "bitflags 2.10.0", "memchr 2.7.6", - "unicase 2.9.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "unicase 2.9.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] @@ -4580,16 +4299,6 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "encoding_rs", - "memchr 2.8.3", -] - [[package]] name = "quinn" version = "0.11.11" @@ -4612,9 +4321,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.17" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", "getrandom 0.4.3", @@ -4799,7 +4508,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f1256e23efe6097f27aa82d6ca6889361c001586ae0f6917cbad072f05eb275" dependencies = [ - "cipher 0.4.4", + "cipher", ] [[package]] @@ -4911,7 +4620,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.12.1", + "hmac", "subtle", ] @@ -5111,15 +4820,15 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid 0.9.6", - "digest 0.10.7", + "const-oid", + "digest", "num-bigint-dig", "num-integer", "num-traits 0.2.19", "pkcs1", "pkcs8", "rand_core 0.6.4", - "sha2 0.10.9", + "sha2", "signature", "spki", "subtle", @@ -5143,7 +4852,7 @@ dependencies = [ [[package]] name = "rustc-hash" version = "1.1.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "rustc-hash" @@ -5232,7 +4941,7 @@ dependencies = [ [[package]] name = "rustybuzz" version = "0.18.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "bitflags 2.10.0", "bytemuck 1.25.0", @@ -5272,7 +4981,7 @@ dependencies = [ [[package]] name = "scoped-tls" version = "1.0.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "scopeguard" @@ -5283,7 +4992,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sdfer" version = "0.2.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "sec1" @@ -5305,7 +5014,7 @@ version = "5.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" dependencies = [ - "aes 0.8.4", + "aes", "cbc", "futures-util", "generic-array", @@ -5314,7 +5023,7 @@ dependencies = [ "num", "once_cell 1.21.4", "serde", - "sha2 0.10.9", + "sha2", "zbus", ] @@ -5427,18 +5136,7 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", - "cpufeatures 0.3.0", - "digest 0.11.3", + "digest", ] [[package]] @@ -5449,18 +5147,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", - "cpufeatures 0.3.0", - "digest 0.11.3", + "digest", ] [[package]] @@ -5485,14 +5172,14 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest 0.10.7", + "digest", "rand_core 0.6.4", ] [[package]] name = "simd-adler32" version = "0.3.9" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "simd-adler32" @@ -5509,7 +5196,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" version = "1.15.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "smallvec" @@ -5539,7 +5226,7 @@ dependencies = [ [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "bitflags 2.13.1", ] @@ -5557,9 +5244,6 @@ dependencies = [ [[package]] name = "spreadsheet-engine" version = "0.1.0" -dependencies = [ - "calamine", -] [[package]] name = "spreadsheet-ui" @@ -5567,7 +5251,6 @@ version = "0.1.0" dependencies = [ "makepad-test", "makepad-widgets", - "robius-file-picker", "spreadsheet-engine", ] @@ -5682,7 +5365,7 @@ dependencies = [ [[package]] name = "thiserror" version = "2.0.18" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "thiserror-impl 2.0.18", ] @@ -5710,9 +5393,9 @@ dependencies = [ [[package]] name = "thiserror-impl" version = "2.0.18" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "makepad-micro-proc-macro 1.0.0 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "makepad-micro-proc-macro", ] [[package]] @@ -5733,7 +5416,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "js-sys", "num-conv", "powerfmt", "serde_core", @@ -5975,13 +5657,7 @@ checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" [[package]] name = "ttf-parser" version = "0.24.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" - -[[package]] -name = "typed-path" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "typenum" @@ -6015,22 +5691,22 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicase" version = "2.9.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "unicode-bidi" version = "0.3.18" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "unicode-bidi-mirroring" version = "0.3.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "unicode-ccc" version = "0.3.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "unicode-ident" @@ -6041,7 +5717,7 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-linebreak" version = "0.1.5" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "unicode-normalization" @@ -6055,17 +5731,17 @@ dependencies = [ [[package]] name = "unicode-properties" version = "0.1.4" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "unicode-script" version = "0.5.8" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "unicode-segmentation" version = "1.12.0" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "unicode-segmentation" @@ -6391,7 +6067,7 @@ dependencies = [ [[package]] name = "wayland-backend" version = "0.3.12" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "downcast-rs", "libc", @@ -6403,7 +6079,7 @@ dependencies = [ [[package]] name = "wayland-client" version = "0.31.12" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "bitflags 2.13.1", "libc", @@ -6413,7 +6089,7 @@ dependencies = [ [[package]] name = "wayland-egl" version = "0.32.9" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "wayland-backend", "wayland-sys", @@ -6422,7 +6098,7 @@ dependencies = [ [[package]] name = "wayland-protocols" version = "0.32.10" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "bitflags 2.13.1", "wayland-backend", @@ -6432,7 +6108,7 @@ dependencies = [ [[package]] name = "wayland-sys" version = "0.31.8" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "log 0.4.29", "pkg-config 0.3.32", @@ -6476,7 +6152,7 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "weezl" version = "0.1.12" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "winapi-util" @@ -6500,19 +6176,19 @@ dependencies = [ [[package]] name = "windows" version = "0.62.2" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ "windows-collections", - "windows-core 0.62.2 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "windows-core 0.62.2 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", "windows-future", ] [[package]] name = "windows-collections" version = "0.3.2" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "windows-core 0.62.2 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "windows-core 0.62.2 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] @@ -6543,19 +6219,19 @@ dependencies = [ [[package]] name = "windows-core" version = "0.62.2" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "windows-link 0.2.1 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", - "windows-result 0.4.1 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", - "windows-strings 0.5.1 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "windows-link 0.2.1 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", + "windows-result 0.4.1 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", + "windows-strings 0.5.1 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] name = "windows-future" version = "0.3.2" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "windows-core 0.62.2 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "windows-core 0.62.2 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] @@ -6611,7 +6287,7 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-link" version = "0.2.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "windows-native-keyring-store" @@ -6647,9 +6323,9 @@ dependencies = [ [[package]] name = "windows-result" version = "0.4.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "windows-link 0.2.1 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "windows-link 0.2.1 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] @@ -6664,9 +6340,9 @@ dependencies = [ [[package]] name = "windows-strings" version = "0.5.1" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" dependencies = [ - "windows-link 0.2.1 (git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251)", + "windows-link 0.2.1 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] [[package]] @@ -6844,7 +6520,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ - "const-oid 0.9.6", + "const-oid", "der", "spki", "tls_codec", @@ -6976,7 +6652,7 @@ dependencies = [ [[package]] name = "zerocopy" version = "0.8.39" -source = "git+https://gitdab.com/andodeki/makepad?rev=4a166606c08867de216125ae34909bc0a1115251#4a166606c08867de216125ae34909bc0a1115251" +source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" [[package]] name = "zerocopy" @@ -7063,9 +6739,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -7074,99 +6750,26 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", "syn 3.0.3", ] -[[package]] -name = "zip" -version = "8.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" -dependencies = [ - "aes 0.9.2", - "bzip2", - "constant_time_eq", - "crc32fast", - "deflate64", - "flate2", - "getrandom 0.4.3", - "hmac 0.13.0", - "indexmap 2.14.0", - "lzma-rust2", - "memchr 2.8.3", - "pbkdf2", - "ppmd-rust", - "sha1 0.11.0", - "time", - "typed-path", - "zeroize", - "zopfli", - "zstd", -] - -[[package]] -name = "zlib-rs" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" - [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log 0.4.33", - "simd-adler32 0.3.10", -] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config 0.3.34", -] - [[package]] name = "zvariant" -version = "5.15.0" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" dependencies = [ "endi", "enumflags2", @@ -7179,9 +6782,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -7192,9 +6795,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "4.2.0" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index e599ec9..dcecdd2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,7 +71,6 @@ members = [ "crates/apps/spreadsheet/spreadsheet-engine", "crates/apps/spreadsheet/spreadsheet-ui", "crates/apps/doc/doc-engine", - "crates/apps/doc/doc-ui", "crates/apps/pdf/pdf-cos", "crates/apps/pdf/pdf-document", "crates/apps/pdf/pdf-graphics", @@ -94,19 +93,3 @@ lto = true # Enable link-time optimization codegen-units = 1 # Reduce number of codegen units to increase optimizations panic = 'abort' # Abort on panic strip = true - -# Single source of truth for the makepad fork. Bump the rev here and every -# crate that declares these deps via `workspace = true` adopts it at once. -[workspace.dependencies] -makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } -makepad-test = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } -makepad-platform = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } -makepad-draw = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } -makepad-derive-widget = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } -makepad-fast-inflate = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } -makepad-mbtile-reader = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } -makepad-script = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } -makepad-code-editor = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } -makepad-xr = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } -makepad-ai = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } -makepad-base64 = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } diff --git a/REVIEWS/FAB_CAD_GAP_IMPLEMENTATION_PLAN.md b/REVIEWS/FAB_CAD_GAP_IMPLEMENTATION_PLAN.md deleted file mode 100644 index f57fed1..0000000 --- a/REVIEWS/FAB_CAD_GAP_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,232 +0,0 @@ -# Fab → nigig-build CAD gap implementation plan - -- **Date:** 2026-08-27 -- **Baseline:** code-verified review of `makepad/libs/fab` (gitdab work branch) vs - `crates/apps/nigig-build/src/.../workspace/cad` (current HEAD). -- **Scope:** port user-facing capabilities from the `fab` reference CAD into the - nigig-build CAD, **adapted to our architecture**, not wholesale copy. -- **Adaptation rules (from earlier decisions):** - 1. Port **algorithms/logic** from fab; keep our `CadNode` flat arena, our - batching/instancing renderer, our mobile-first 430x860 DSL, our tool model. - 2. fab is an **architecture viewer/inspection** app (measure, section, isolate, - explode, sun study, walk, render) — it has **no geometry-authoring tools**. - Our CAD already has authoring (18 tools). So every port target is a - *reader/inspection/rendering* feature we lack, layered on our existing model. - 3. fab communicates only through `api.rs` `ShellAction`s + `AppState`. We adapt - that as: workspace button/action handlers + our existing dirty-flag sync. -- **Completion standard (echoes repo convention):** a phase is done only when its - numbered items all land with unit tests, compile clean, and `cargo test -p - nigig-build --lib` stays green. - ---- - -## Inventory: what fab has that we lack (verified) - -| fab capability | file | status in our CAD | -|---|---|---| -| Section planes (drag handle, caps, GPU discard) | `tools/section.rs`, `viewport/dsl.rs` | **missing** — big gap | -| Explode view (by-storey / by-element) | `tools/explode.rs` | **missing** | -| Sun study (NOAA solar, day/hour scrub, compass) | `tools/sun_study.rs`, `tools/overlay.rs` | **missing** | -| Full object snap incl. midpoints/face + glyph preview | `tools/snap.rs` (we have most already) | **partial** — we lack glyph/ghost preview + normal | -| Element info card (I) + reveal in outliner | `tools/info.rs` | **missing** | -| Command palette (F3 fuzzy) + keymap help (F1) | `ui/command_palette.rs`, `ui/keymap.rs` | **missing** — high value, low risk | -| Isolate/solo/hide/unhide (H/Shift+H/Alt+H, `/`) | `tools/isolate.rs` | **partial** — we have isolate (I) + per-part hide via outliner; no solo, no unhide-all hotkey | -| F12 high-res render + Save PNG / track-to-mp4 | `render/mod.rs` | **missing** (we have ray mode, no export render) | -| Progressive path-traced preview | `viewport/mod.rs` | **missing** (we have Realistic/Ray view modes) | -| Drag-number / value field; colour picker | `ui/dragnum.rs`, `ui/colorpick.rs` | **partial** — we have numeric TextInputs; no drag+fine control | -| X-ray toggle; 6 shading modes | `api.rs`, `viewport/dsl.rs` | **partial** — we have 6 modes via display_mode; no x-ray | -| `●`/`○` outliner (done), gets search + type filter | `ui/outliner.rs` | **partial** — done base; no search/filter/funnel | -| Predefined camera views (front/right/top/iso) | `nav/`, `api.rs`, `keymap.rs` | **partial** — we have Alt+1-8 presets already | -| Frustum culling + BVH (element-level) | `model/bvh.rs` | **done** — we already ported BVH + frustum culling | -| Instancing/batching by shared shape | — | **done** — we have ShapeHash instancing | -| Measure distance/angle/area | `tools/measure.rs` | **done** — ported | -| Per-part visibility honored in render/pick/snap | `viewport/elements.rs` | **done** (new in this session) | -| Properties panel readout | `ui/properties.rs` | **partial** — we have X/Y/Z inputs + kind; no IFC-ish grouped props | - ---- - -## Phase A — Command palette + keyboard map (highest value, lowest risk) - -**Why first:** delivers broad discoverability and requires no new GPU/scene work; -reuses our existing workspace action handlers and already-mapped hotkeys. - -1. **Pure command table** `command_palette.rs`: `Vec`. - Commands = existing actions we already support: frame all (fit), frame selected, - preset views (F5/Alt+1-8), ortho toggle, shading modes, isolate, hide/show all, - toggle outliner, undo/redo, open/save, exit. Each `run` dispatches to the same - `CadWorkspace` handlers our toolbar buttons already call. -2. **Fuzzy subsequence matcher** (pure fn, unit-tested) — port fab's scoring - (subsequence + prefix/word-start bonus) exactly. -3. **Palette overlay** in the mobile DSL (a `View` list + filter `TextInput`, - arrow-keys + Enter), toggled by the existing keymap or a toolbar button. -4. **Keymap table** `keymap.rs` — single source of truth for our hotkeys; render an - **F1 help** panel from it (like fab). Unit test that every key maps to a real action. - -**Acceptance:** palette filters and runs ≥6 commands with tests; F1 help renders from -the table; `parameter.palette` tests green; full lib suite green. - ---- - -## Phase B — Isolate/solo/hide/unhide parity (small, our mechanism) - -**Adapt:** fab uses an *isolation set* / solo mode; we use `__hidden__` name prefix -(made real this session). Extend, do not rewrite. - -1. `CadViewport::solo_selected` — isolate to the selection; toggle off on repeat - (`isolate_selected` already does exactly this — expose as hotkey + outliner button). -2. `CadViewport::unhide_all` — alias for existing `show_all`; bind **Alt+H**. -3. Bind **H** = isolated-selected (currently `I`), keep `I` too. Unit test - `isolate_selected` round-trips (hide then restore) — add a test now that the - visibility mechanism is honored. - -**Acceptance:** hotkeys + 2 unit tests (isolate round-trip, solo toggle); lib green. - ---- - -## Phase C — Element info card + reveal in outliner - -**Adapt:** fab's `I` tool card shows type/storey/layer/GUID/size/tri-count/quantities. -We have no storey/layer UI per part but have `CadNode` fields (name, kind, pos, size, -color, layer) + mesh tri-count via `scene_cache`. - -1. `properties.rs` or new `info_card.rs`: pure `info_card_text(&CadNode, tri_count)` - returning the multi-line card (kind, id, name, pos, size, layer, tris). Unit-tested. -2. Draw the card as a small label overlay near the hovered part in `viewport_render.rs` - (2D + 3D), or reuse the status bar when parked. Follow fab's "click focuses and - reveals in outliner" by opening the outliner and selecting the part. - -**Acceptance:** `info_card_text` tests; overlay/status wiring compiles; lib green. - ---- - -## Phase D — Section planes (largest rendering gap) - -**Scope honestly:** fab's section = GPU half-space discard + caps in `dsl.rs`. We use -a different renderer (`DrawCadMesh` shader, display_mode uniform). A faithful port is -large: add half-space uniforms to the shader + caps pass + drag handle + panel. - -**Adapted approach (bounded):** -1. **CPU clip** in `viewport_render.rs`: when a section plane is active, keep only - parts whose AABB is entirely inside the kept half-spaces; draw a plane outline + - normal arrow overlay (reuse our existing overlay drawing). This gives the *editor - UX* (see the cut live, drag to move) without touching the shader. -2. `section.rs` (pure): `SectionPlane{ normal, offset }`, `kept(aabb) -> bool`, - `plane_through(p0, normal)`, offset/with_offset helpers — port from fab, unit-test. -3. Panel: `SetSection` buttons (axis, flip, clear) in the outliner/properties panel. -4. **Shader caps (stretch, gate):** add a CLIP uniform + cap fill only if CPU clip is - judged insufficient after a measurement of real scenes. Keep out of the first cut. - -**Acceptance:** `section.rs` unit tests; CPU-clip + overlay compiles and draws; no -regression in lib suite. **Phase marked done even without GPU caps**, which are an -explicitly-gated stretch (named as external-effort, consistent with the completion -standard). - ---- - -## Phase E — Explode view - -**Adapt:** our parts have no "storey" grouping by default; support **by-element** -radial explode first, include **by-storey** only if a grouping exists (outliner could -group by `layer`). - -1. `explode.rs` (pure): `ExplodeMode{ ByElement }`, `ExplodeState{ amount }`, - `element_offset(id_idx, centre, amount)` — port fab's radial rule, unit-test. -2. Apply offsets in `part_model_matrix_cadnode`/the draw when explode active - (transform-time, so pick/snap reuse the same offset — no LUT needed). -3. `ExplodeState` stored on `CadViewport`; slider in the outliner panel actions. - -**Acceptance:** `explode.rs` tests (element 0 offset = 0; radial sign/direction); -transform application compiles; lib green. - ---- - -## Phase F — Sun study - -**Adapt:** pure NOAA solar model (azimuth/elevation from lat/lon/date/time) + a day -scrub. Our CAD has a real `u_light_dir` uniform (per `DrawCadMesh`), so the sun can -drive the existing key light + a cast-shadow plane fill. - -1. `sun.rs` (pure): `SunSettings{ latitude, longitude, date, hour }`, NOAAlike - `solar_position() -> (azimuth_deg, elevation_deg)`, `compass_point()`, - `direction() -> Vec3f` — port from `api::SkyState` and `sun_study.rs`, unit-test - against known noon values. -2. Toolbar button opens a small sun panel (date/hour/latitude, play scrub) reusing - the drag-number/TextInput style; set `u_light_dir` from `direction()` in - `viewport_render.rs`. -3. Overlay sun-compass (arc + disc + readout) drawn in the viewport — port the - math, keep our draw style. - -**Acceptance:** `sun.rs` tests (elevation sign at noon, compass names); light-dir -wiring compiles; overlay compiles; lib green. - ---- - -## Phase G — F12 high-res render + Save PNG - -**Adapt:** fab uses a progressive path-traced preview + `FabRenderView`. Our CAD has -a **Ray** shading mode via `display_mode` but no standalone capture. Minimal: -1. `RenderSettings{ width, height, samples }` state on `CadWorkspace`. -2. "Render" action captures the current scene at render resolution using our - existing DrawCadMesh into an offscreen target, accumulates, and **writes a PNG** - (we already export PNG from the arch_pdf path, so the encoder exists — reuse it). -3. Command-palette entry `render-image` (F12). - -**Acceptance:** a `render settings` pure struct + tests; the PNG write path is wired -through an existing tested encoder; no new dependency; lib green. - ---- - -## Phase H — X-ray + shading parity + value-field polish (fill-in gaps) - -1. **X-ray:** add an `xray` overlay uniform to `DrawCadMesh` (or reuse display_mode - degree), toggled by `Alt+Z` + a toolbar button; only affects the shader, tested by - `parameter` snapshot if present. -2. **Drag-number:** port fab's pure `header_drag_math` (anchor/step/fine/ctrl) as a - Rust fn with tests, and wrap our existing numeric `TextInput`s where ergonomic - (properties panel X/Y/Z/W/H/D). Keep current inputs working. -3. **Outliner search + type filter:** add a `TextInput` filter in the outliner panel; - pure filter fn `filter_rows(rows, query) -> Vec<..>` unit-tested; funnel dropdown - filters by `PartKind`. - -**Acceptance:** per-item tests; no regression; lib green. - ---- - -## Explicitly NOT porting (with reason) - -- **fab's `api.rs` shell/`ShellAction` dictionary** — our app has a different action - model and mobile-first layout; adopting it would be a rewrite. -- **`ui/shell.rs` dock / `area.rs` swappable editors / `menubar.rs`** — desktop-chrome - that our 430x860 mobile UI does not host; our toolbar + bottom sheet already cover it. -- **`render/mod.rs` camera-track to mp4** — needs movie encoding we don't ship. -- **`file_browser.rs` / in-app open dialog** — platform has no file picker; gated on - a platform capability, not effort (matches the completion-standard exception). -- **`ui/colorpick.rs` full hue-ring picker** — nice-to-have; we have a 9-swatch palette; - deferred unless requested. -- **`nav/gizmo.rs` axis-ball gizmo** — we have a nav pad + preset views; low ROI. -- **`ui/dragnum.rs` drag-number value field** — parity with fab: we have numeric - `TextInput`s in the properties panel; full drag+fine-control (anchor/step/ctrl) - is a UX polish, not an inspection capability. ([cross-ref Phase H.2](deferred).) -- **`render/mod.rs` progressive path-traced preview (live noise-accumulating view)** - — we ship Quality/Realistic/Ray shading modes already; porting fab's live - progressive preview to our GPU path is large and gated. See Phase G for the - bounded capture/export path we *do* ship. - ---- - -## Recommended order & effort - -| Phase | Effort | Risk | Do first? | -|---|---|---|---| -| A Command palette + keymap | S | low | ✅ yes | -| B Isolate/solo/outliner parity | XS | low | ✅ yes | -| C Info card + reveal | S | low | ✅ yes | -| D Section planes (CPU clip) | M | med | next | -| E Explode | S | low | next | -| F Sun study | M | med | later | -| G F12 render + PNG | M | med | later | -| H X-ray/dragnum/outliner search | M | med | last | - -S = small, M = medium. Each phase ends with unit tests + green `--lib` suite, and the -GPU-heavy items (D caps, F shadows) are gated as explicit named work rather than -silently dropped. diff --git a/crates/apps/doc/doc-ui/Cargo.toml b/crates/apps/doc/doc-ui/Cargo.toml deleted file mode 100644 index 0fc112e..0000000 --- a/crates/apps/doc/doc-ui/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "doc-ui" -version = "0.1.0" -edition = "2021" -description = "Makepad widget wrappers for the CRDT document engine." -publish = false - -[dependencies] -makepad-widgets = { workspace = true, features = ["test"] } -doc-engine = { path = "../doc-engine" } -nigig-core = { path = "../../../nigig-core" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -robius-file-picker = { git = "https://github.com/project-robius/robius", rev = "b766e62b0600f5d2ee21cc6995648346fc277bd8" } -zip = "8" -quick-xml = "0.41" - -[dev-dependencies] -makepad-test = { workspace = true } \ No newline at end of file diff --git a/crates/apps/doc/doc-ui/src/dashboard.rs b/crates/apps/doc/doc-ui/src/dashboard.rs deleted file mode 100644 index 375146d..0000000 --- a/crates/apps/doc/doc-ui/src/dashboard.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! `DocDashboard` widget: the file-list view shown on first launch or -//! before a document is opened. -//! -//! Shows saved documents from the `generated/` directory in a grid of -//! preview cards. A "+ New" button creates a blank document. Clicking a -//! card opens that document. "Import Doc" opens a platform-native file -//! dialog for `.docx`/`.odt`/`.rtf`/`.txt`/`.md`/`.doc.json`. - -use makepad_widgets::makepad_platform::event::TouchState; -use makepad_widgets::*; - -use crate::persistence::{list_saved_docs, DocEntry}; - -/// Emitted to the workspace when the dashboard wants to switch views. -#[derive(Clone, Debug)] -pub enum DocDashboardAction { - /// Create a new blank document (replaces the current model). - NewDocument, - /// Open an existing saved document by filename (from `generated/`). - OpenFile(String), - /// User wants to go back to the dashboard. - BackToDashboard, - /// User wants to import an external document from a platform-native - /// file dialog. - ImportDocument, -} - -#[derive(Script, ScriptHook, Widget)] -pub struct DocDashboard { - #[deref] - view: View, - #[rust] - files: Vec, - #[rust] - pub action: Option, - #[rust] - initialized: bool, - - // --- Draw resources for the file card grid (manual rendering) --- - #[live] - draw_card_bg: DrawColor, - #[live] - draw_card_hover_bg: DrawColor, - #[live] - draw_card_text: DrawText, - #[live] - draw_card_preview: DrawText, - #[live] - card_normal_color: Vec4f, - #[live] - card_hover_color: Vec4f, - #[live] - card_text_color: Vec4f, - #[live] - card_preview_color: Vec4f, - - /// Hit-test areas for each file card. - #[rust] - card_areas: Vec<(usize, Rect)>, - #[rust] - rect: Rect, - - /// Index of the card currently under the cursor (for hover highlight). - #[rust] - hover_card: Option, -} - -/// Toggle the dashboard's own visibility for the workspace overlay. -/// The runtime widget is this component, so the workspace cannot -/// downcast it to a plain `View`; this forwards to the component's -/// root view instead. -impl DocDashboard { - pub fn set_dash_visible(&mut self, cx: &mut Cx, visible: bool) { - self.view.set_visible(cx, visible); - } -} - -impl Widget for DocDashboard { - fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - self.view.handle_event(cx, event, scope); - - if let Event::Actions(actions) = event { - self.handle_actions(cx, actions, scope); - } - - self.handle_card_clicks(cx, event); - } - - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - if !self.initialized { - self.refresh_files(); - self.initialized = true; - } - - let draw_step = self.view.draw_walk(cx, scope, walk); - - self.rect = self.view.area().rect(cx); - - if !self.files.is_empty() { - self.draw_cards(cx); - } - - draw_step - } -} - -impl DocDashboard { - /// Refresh the file listing from disk. - pub fn refresh_files(&mut self) { - self.files = list_saved_docs(); - self.card_areas.clear(); - } - - /// Called by the workspace when it becomes visible. - pub fn refresh_and_redraw(&mut self, cx: &mut Cx) { - self.files = list_saved_docs(); - self.card_areas.clear(); - self.view.redraw(cx); - } - - /// Draw document preview cards onto the canvas. - fn draw_cards(&mut self, cx: &mut Cx2d) { - self.card_areas.clear(); - - let area = self.view.area().rect(cx); - let card_w = 240.0_f64; - let card_h = 100.0_f64; - let margin_x = 16.0_f64; - let margin_y = 80.0_f64; - let spacing_x = 20.0_f64; - let spacing_y = 16.0_f64; - - let cols = ((area.size.x - margin_x * 2.0 + spacing_x) / (card_w + spacing_x)) as usize; - let cols = cols.max(1); - - let mut col = 0usize; - let mut row = 0usize; - - for (i, entry) in self.files.iter().enumerate() { - let x = area.pos.x + margin_x + col as f64 * (card_w + spacing_x); - let y = area.pos.y + margin_y + row as f64 * (card_h + spacing_y); - - let card_rect = Rect { - pos: DVec2 { x, y }, - size: DVec2 { - x: card_w, - y: card_h, - }, - }; - - let is_hovered = self.hover_card == Some(i); - self.draw_card_bg.color = if is_hovered { - self.card_hover_color - } else { - self.card_normal_color - }; - self.draw_card_bg.draw_abs(cx, card_rect); - - // Title (first paragraph). - self.draw_card_text.color = self.card_text_color; - let title = if entry.title.is_empty() { "(empty)" } else { &entry.title }; - self.draw_card_text.draw_abs( - cx, - DVec2 { - x: card_rect.pos.x + 12.0, - y: card_rect.pos.y + 12.0, - }, - title, - ); - - // Preview snippet. - let preview = if entry.preview.is_empty() { - "(empty)" - } else { - &entry.preview - }; - self.draw_card_preview.color = self.card_preview_color; - self.draw_card_preview.draw_abs( - cx, - DVec2 { - x: card_rect.pos.x + 12.0, - y: card_rect.pos.y + 34.0, - }, - preview, - ); - - // Filename. - self.draw_card_preview.draw_abs( - cx, - DVec2 { - x: card_rect.pos.x + 12.0, - y: card_rect.pos.y + 52.0, - }, - &entry.filename, - ); - - // File size. - let size_str = if entry.size < 1024 { - format!("{} B", entry.size) - } else { - format!("{:.1} KB", entry.size as f64 / 1024.0) - }; - self.draw_card_preview.draw_abs( - cx, - DVec2 { - x: card_rect.pos.x + 12.0, - y: card_rect.pos.y + 68.0, - }, - &size_str, - ); - - self.card_areas.push((i, card_rect)); - - col += 1; - if col >= cols { - col = 0; - row += 1; - } - } - } - - /// Handle clicks on document cards. - fn handle_card_clicks(&mut self, cx: &mut Cx, event: &Event) { - if let Hit::FingerMove(fme) = event.hits_with_capture_overload(cx, self.draw_bg.area(), true) { - let pos = fme.abs; - let new_hover = self - .card_areas - .iter() - .find(|(_, rect)| rect.contains(pos)) - .map(|(i, _)| *i); - if new_hover != self.hover_card { - self.hover_card = new_hover; - self.view.redraw(cx); - } - } - - if let Event::TouchUpdate(tu) = event { - for touch in &tu.touches { - if touch.state == TouchState::Stop { - for &(idx, rect) in &self.card_areas { - if rect.contains(touch.abs) { - let filename = self.files[idx].filename.clone(); - self.action = Some(DocDashboardAction::OpenFile(filename)); - return; - } - } - } - } - } - - if let Hit::FingerUp(fe) = event.hits_with_capture_overload(cx, self.draw_bg.area(), true) { - if fe.is_primary_hit() { - for &(idx, rect) in &self.card_areas { - if rect.contains(fe.abs) { - let filename = self.files[idx].filename.clone(); - self.action = Some(DocDashboardAction::OpenFile(filename)); - return; - } - } - } - } - } - - fn create_new_document(&mut self, cx: &mut Cx) { - self.action = Some(DocDashboardAction::NewDocument); - self.view.redraw(cx); - } -} - -impl WidgetMatchEvent for DocDashboard { - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { - if self.button(cx, ids!(new_document_btn)).clicked(actions) { - self.create_new_document(cx); - } - - if self.button(cx, ids!(refresh_btn)).clicked(actions) { - self.refresh_and_redraw(cx); - } - - if self.button(cx, ids!(back_btn)).clicked(actions) { - self.action = Some(DocDashboardAction::BackToDashboard); - self.view.redraw(cx); - } - - if self.button(cx, ids!(import_doc_btn)).clicked(actions) { - self.action = Some(DocDashboardAction::ImportDocument); - self.view.redraw(cx); - } - } -} \ No newline at end of file diff --git a/crates/apps/doc/doc-ui/src/doc_import.rs b/crates/apps/doc/doc-ui/src/doc_import.rs deleted file mode 100644 index 2e8177d..0000000 --- a/crates/apps/doc/doc-ui/src/doc_import.rs +++ /dev/null @@ -1,598 +0,0 @@ -//! External document import for the doc dashboard. -//! -//! Reads `.doc.json` (the app's own save format), `.docx`, `.odt`, -//! `.rtf`, `.txt` and `.md` files and converts them into the `#MP_CRDT_V1` -//! wire format both editors consume. Text extraction is deliberately -//! lightweight: `zip` + `quick-xml` walk the package XML, `.rtf` is -//! control-word stripped, and plain text is split into paragraphs. The -//! goal is readable prose, not a lossless round-trip of the source format. - -use std::io::Read; -use std::path::Path; - -use doc_engine::controller::DocumentController; -use doc_engine::crdt::OpId; - -use crate::projection_session::crdt_save_wire; - -/// A document produced from an import: the `#MP_CRDT_V1` wire for either -/// editor plus dashboard metadata. -pub struct ImportedDoc { - /// Serialization accepted by `DocEditor::deserialize` and - /// `CrdtDocEditor::deserialize`. - pub wire: String, - /// First non-empty paragraph, or a fallback derived from the file. - pub title: String, - /// Preview snippet of the body text. - pub preview: String, -} - -/// Outcome of the async document-import file picker. -/// -/// The `robius-file-picker` completion callback runs off the UI thread with -/// no `Cx`, so it parks the outcome here and raises a UI signal; the -/// workspace's `drain_doc_import` applies it on the next `Event::Signal`. -/// This is the same shape the spreadsheet/invoicer apps use. -pub enum DocImportOutcome { - Picked(std::path::PathBuf), - Failed(String), -} - -static PENDING_DOC_IMPORT: std::sync::Mutex> = - std::sync::Mutex::new(None); - -/// Lock (recovering a poisoned guard), park an outcome, and poke the UI -/// thread. -fn park_import(outcome: DocImportOutcome) { - let mut guard = PENDING_DOC_IMPORT - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *guard = Some(outcome); - makepad_widgets::SignalToUI::set_ui_signal(); -} - -/// Take any parked file-picker outcome, if one is pending. Called on -/// `Event::Signal`. -pub fn take_pending_import() -> Option { - let mut guard = PENDING_DOC_IMPORT - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - guard.take() -} - -/// Open the platform file dialog for a document file. -/// -/// `robius-file-picker` rather than makepad's own dialog, which is -/// implemented on macOS only — the Linux and Android backends never -/// handle `CxOsOp::SelectFileDialog`, so the button would do nothing on -/// the platforms this repo targets. -pub fn open_document_dialog() -> Result<(), String> { - use robius_file_picker::FileDialog; - - FileDialog::new() - .set_title("Import Document") - .add_filter( - "Documents", - &["docx", "odt", "rtf", "txt", "md", "json", "doc"], - ) - .pick_file(|outcome| match outcome { - Ok(Some(picked)) => match picked.path() { - Some(path) => park_import(DocImportOutcome::Picked(path.to_path_buf())), - None => park_import(DocImportOutcome::Failed( - "That file has no local path this app can read".to_string(), - )), - }, - // Cancelled: say nothing and change nothing. - Ok(None) => {} - Err(e) => park_import(DocImportOutcome::Failed(format!( - "File picker failed: {e}" - ))), - }) - .map_err(|err| err.to_string()) -} - -/// Import a document from an external file, chosen by extension. -pub fn import_document_from_path(path: &Path) -> Result { - let text = std::fs::read(path) - .map_err(|err| format!("could not read {}: {err}", path.display()))?; - - let lower = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or_default() - .to_ascii_lowercase(); - let ext = path - .extension() - .and_then(|s| s.to_str()) - .unwrap_or_default() - .to_ascii_lowercase(); - - if lower.ends_with(".doc.json") || ext == "json" { - return import_native(&text); - } - let paragraphs = match ext.as_str() { - "docx" => parse_docx(&text)?, - "odt" => parse_odt(&text)?, - "rtf" => paragraphize(&strip_rtf(&String::from_utf8_lossy(&text))), - "txt" | "md" => paragraphize(&String::from_utf8_lossy(&text)), - other => { - return Err(format!( - "unsupported document format: {}", - if other.is_empty() { "(none)" } else { other } - )) - } - }; - finish_text_import(¶graphs) -} - -/// Native `.doc.json`: the content is either `#MP_CRDT_V1` wire or the -/// legacy `M|...` delimiter format. The wire passes through untouched; -/// legacy is converted to wire so it opens in both editors. -fn import_native(bytes: &[u8]) -> Result { - let content = String::from_utf8_lossy(bytes).into_owned(); - let paragraphs = plain_text_paragraphs(&content); - let (title, preview) = title_and_snippet(¶graphs); - let wire = if content.starts_with(crate::projection_session::CRDT_SAVE_HEADER) { - content - } else { - legacy_to_wire(¶graphs)? - }; - Ok(ImportedDoc { - wire, - title, - preview, - }) -} - -/// Wrap extracted paragraphs into a CRDT wire document. -fn finish_text_import(paragraphs: &[String]) -> Result { - let (title, preview) = title_and_snippet(paragraphs); - let wire = build_wire_from_paragraphs(paragraphs)?; - Ok(ImportedDoc { - wire, - title, - preview, - }) -} - -/// `plain_text_paragraphs` as a `DocPreview`-style pair: first non-empty -/// paragraph is the title, up to 160 chars of the body is the preview. -fn title_and_snippet(paragraphs: &[String]) -> (String, String) { - let title = paragraphs - .iter() - .find(|p| !p.trim().is_empty()) - .cloned() - .unwrap_or_default(); - let mut snippet = String::new(); - for p in paragraphs { - let trimmed = p.trim(); - if trimmed.is_empty() { - continue; - } - if !snippet.is_empty() { - snippet.push(' '); - } - snippet.push_str(trimmed); - if snippet.chars().count() >= 160 { - snippet = snippet.chars().take(160).collect(); - snippet.push_str("…"); - break; - } - } - (title, snippet) -} - -/// Split a raw text blob into non-empty trimmed paragraphs on blank lines. -fn paragraphize(text: &str) -> Vec { - text.split('\n') - .map(|line| line.trim().to_string()) - .collect() -} - -/// Convert the legacy `P|`/`H` delimiter save to `#MP_CRDT_V1` wire, so a -/// classic-format import opens in the CRDT editor too. -fn legacy_to_wire(paragraphs: &[String]) -> Result { - let paragraphs: Vec = paragraphs - .iter() - .filter(|p| !p.trim().is_empty()) - .cloned() - .collect(); - build_wire_from_paragraphs(¶graphs) -} - -/// Build a `#MP_CRDT_V1` wire document, one paragraph block per string. -/// Empty paragraphs are skipped; an all-empty input yields an empty -/// paragraph-block document rather than failing (imports always succeed, -/// even for a blank file). -pub fn build_wire_from_paragraphs(paragraphs: &[String]) -> Result { - build_wire(paragraphs, true) -} - -/// Wire for a fresh blank document: a single empty paragraph block. The CRDT -/// editor's `init_document` gate only fires on an empty projection, so a new -/// page must have at least one block or it gets replaced by the saved -/// document on the first event. -pub fn blank_document_wire() -> Result { - build_wire(&[String::new()], false) -} - -fn build_wire(paragraphs: &[String], skip_blank: bool) -> Result { - let mut controller = DocumentController::default(); - let mut last: Option = None; - for paragraph in paragraphs { - let text = paragraph.trim(); - if skip_blank && text.is_empty() { - continue; - } - let block = controller - .insert_block("import", last.clone(), "paragraph") - .ok_or("could not create a paragraph block")?; - if !text.is_empty() { - controller.insert_text("import", block.clone(), None, text); - } - last = Some(block); - } - crdt_save_wire(&controller.document).ok_or_else(|| "could not serialize the imported document".to_string()) -} - -/// Extract the paragraph texts of a document save: `#MP_CRDT_V1` JSON or -/// the legacy delimiter format. Used by the dashboard previews. -pub fn plain_text_paragraphs(content: &str) -> Vec { - if let Some(json) = content.strip_prefix(crate::projection_session::CRDT_SAVE_HEADER) { - if let Ok(document) = doc_engine::crdt::CrdtDocument::from_json(json) { - let projection = document.materialize(); - return projection.blocks.iter().map(|block| block.text.clone()).collect(); - } - } - // Legacy delimiter format: `P|align|text§…§~` and `H{level}|align|…`. - let mut out = Vec::new(); - for line in content.lines() { - let mut parts = line.splitn(3, '|'); - let kind = parts.next().unwrap_or(""); - if kind == "P" || kind.starts_with('H') { - if let Some(spans) = parts.nth(1) { - let mut text = String::new(); - for (index, chunk) in spans.split('§').enumerate() { - if index % 6 == 0 { - text.push_str(chunk); - } - } - out.push(text); - } - } - } - out -} - -/// Extract paragraphs from a `.docx` (`word/document.xml`) by reading the -/// ZIP entry and walking `w:p` elements. -fn parse_docx(bytes: &[u8]) -> Result, String> { - let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) - .map_err(|err| format!("not a valid .docx zip: {err}"))?; - let mut xml = String::new(); - archive - .by_name("word/document.xml") - .map_err(|err| format!("no word/document.xml in .docx: {err}"))? - .read_to_string(&mut xml) - .map_err(|err| format!("could not read document.xml: {err}"))?; - xml_paragraphs(&xml, b"w:p", &[(b"w:br", "\n"), (b"w:tab", "\t")]) -} - -/// Extract paragraphs from an `.odt` (`content.xml`) by walking `text:p` -/// and `text:h` elements. -fn parse_odt(bytes: &[u8]) -> Result, String> { - let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) - .map_err(|err| format!("not a valid .odt zip: {err}"))?; - let mut xml = String::new(); - archive - .by_name("content.xml") - .map_err(|err| format!("no content.xml in .odt: {err}"))? - .read_to_string(&mut xml) - .map_err(|err| format!("could not read content.xml: {err}"))?; - xml_paragraphs( - &xml, - b"text:p", - &[(b"text:line-break".as_slice(), "\n"), (b"text:tab".as_slice(), "\t")], - ) -} - -/// Generic XML paragraph walker: accumulate text (and CDATA) content under -/// every element whose tag is `paragraph_tag`; `br_tags` holds -/// (self-closing tag, replacement) pairs that insert whitespace. Namespace -/// prefixes stay dynamic, so `.docx` and `.odt` both work. -fn xml_paragraphs( - xml: &str, - paragraph_tag: &[u8], - br_tags: &[(&[u8], &str)], -) -> Result, String> { - use quick_xml::events::Event; - use quick_xml::Reader; - - let mut reader = Reader::from_str(xml); - let mut buf = Vec::new(); - let mut in_paragraph = false; - let mut paragraph = String::new(); - let mut paragraphs = Vec::new(); - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(e)) => { - let qname = e.name(); - let name = qname.as_ref(); - if name == paragraph_tag { - in_paragraph = true; - paragraph.clear(); - } else if in_paragraph { - for (tag, replacement) in br_tags { - if *tag == name { - paragraph.push_str(replacement); - } - } - } - } - Ok(Event::Empty(e)) => { - if in_paragraph { - let qname = e.name(); - let name = qname.as_ref(); - for (tag, replacement) in br_tags { - if *tag == name { - paragraph.push_str(replacement); - } - } - } - } - Ok(Event::Text(e)) => { - if in_paragraph { - let text = e.decode().unwrap_or_default(); - paragraph.push_str( - &quick_xml::escape::unescape(text.as_ref()).unwrap_or_default(), - ); - } - } - Ok(Event::CData(e)) => { - if in_paragraph { - paragraph.push_str(&String::from_utf8_lossy(&e)); - } - } - Ok(Event::End(e)) => { - if e.name().as_ref() == paragraph_tag { - in_paragraph = false; - let trimmed = paragraph.trim().to_string(); - if !trimmed.is_empty() { - paragraphs.push(trimmed); - } - } - } - Ok(Event::Eof) => break, - Err(err) => return Err(format!("malformed document XML: {err}")), - _ => {} - } - buf.clear(); - } - - Ok(paragraphs) -} - -/// Strip RTF control words and grouping braces, keeping readable text. -fn strip_rtf(data: &str) -> String { - let bytes = data.as_bytes(); - let mut out = String::new(); - let mut index = 0usize; - while index < bytes.len() { - let byte = bytes[index]; - match byte { - b'\\' => { - index += 1; - if index >= bytes.len() { - break; - } - let c = bytes[index]; - if c == b'\\' || c == b'{' || c == b'}' { - out.push(c as char); - index += 1; - continue; - } - if c == b'\'' { - if index + 2 < bytes.len() { - if let Ok(v) = u8::from_str_radix(&data[index + 1..index + 3], 16) { - out.push(v as char); - } - } - index += 3; - continue; - } - let word_start = index; - while index < bytes.len() && bytes[index].is_ascii_alphabetic() { - index += 1; - } - let word = &data[word_start..index]; - // Consume the single delimiter space that terminates a - // control word (optional in strict RTF). - if index < bytes.len() && bytes[index] == b' ' { - index += 1; - } - let num_start = index; - while index < bytes.len() - && (bytes[index].is_ascii_digit() || bytes[index] == b'-') - { - index += 1; - } - let num = &data[num_start..index]; - match word { - "par" => out.push('\n'), - "tab" => out.push('\t'), - "u" => { - if let Ok(value) = num.parse::() { - if let Some(ch) = char::from_u32(value as u32) { - out.push(ch); - } - } - if index < bytes.len() { - index += 1; - } - } - _ => {} - } - } - b'{' | b'}' | b'\r' | b'\n' => index += 1, - 0x80..=0xFF => { - let ch = data[index..].chars().next().unwrap_or(' '); - out.push(ch); - index += ch.len_utf8(); - } - _ => { - out.push(byte as char); - index += 1; - } - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - fn wire_paragraphs(wire: &str) -> Vec { - let json = wire.strip_prefix(crate::projection_session::CRDT_SAVE_HEADER).unwrap(); - let document = doc_engine::crdt::CrdtDocument::from_json(json).unwrap(); - document.materialize().blocks.iter().map(|b| b.text.clone()).collect() - } - - #[test] - fn build_wire_round_trips_paragraphs() { - let wire = build_wire_from_paragraphs(&["Hello world".into(), "Second line".into()]) - .expect("wire"); - assert_eq!(wire_paragraphs(&wire), vec!["Hello world", "Second line"]); - assert_eq!(plain_text_paragraphs(&wire), vec!["Hello world", "Second line"]); - } - - #[test] - fn build_wire_accepts_empty_input() { - let wire = build_wire_from_paragraphs(&[].to_vec()).expect("wire"); - assert!(wire.starts_with(crate::projection_session::CRDT_SAVE_HEADER)); - } - - #[test] - fn build_wire_skips_blank_paragraphs() { - let wire = build_wire_from_paragraphs(&[" ".into(), "Hello".into()]).expect("wire"); - assert_eq!(wire_paragraphs(&wire), vec!["Hello"]); - } - - #[test] - fn plain_text_paragraphs_parses_legacy_delimiter() { - let legacy = "M|actor|0|0\nP|Left|Hello§false§false§false§12§~\n\ - H1|Left|Title§false§false§false§14§~\n"; - assert_eq!(plain_text_paragraphs(legacy), vec!["Hello", "Title"]); - } - - #[test] - fn plain_text_paragraphs_ignores_garbage() { - assert!(plain_text_paragraphs("not a document at all").is_empty()); - } - - #[test] - fn strip_rtf_keeps_text() { - let rtf = r"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}}\f0\pard -Hello \b world\par Second \tab line\par}"; - let stripped = strip_rtf(rtf); - assert!(stripped.contains("Hello")); - assert!(stripped.contains("world")); - assert!(stripped.contains('\n')); - assert!(stripped.contains('\t')); - assert!(stripped.contains("Second")); - } - - #[test] - fn strip_rtf_unicode_escape() { - assert_eq!(strip_rtf(r"caf\u233? text"), "café text"); - } - - #[test] - fn xml_paragraphs_walks_paragraphs() { - let xml = r#" - alpha - beta gamma - "#; - assert_eq!( - xml_paragraphs(xml, b"w:p", &[(b"w:br".as_slice(), "\n"), (b"w:tab".as_slice(), "\t")]) - .unwrap(), - vec!["alpha", "beta gamma"] - ); - } - - #[test] - fn docx_extracts_preview_and_wire() { - use std::io::Write; - let doc_xml = r#" - - The Quick Brown Fox - jumps over the lazy dog - "#; - let mut writer = - zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - let options = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); - writer.start_file("word/document.xml", options).unwrap(); - writer.write_all(doc_xml.as_bytes()).unwrap(); - let bytes = writer.finish().unwrap().into_inner(); - - let dir = std::env::temp_dir().join(format!("doc_import_test_{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("sample.docx"); - std::fs::write(&path, &bytes).unwrap(); - - let imported = import_document_from_path(&path).expect("import docx"); - assert_eq!(imported.title, "The Quick Brown Fox"); - assert_eq!( - imported.preview, - "The Quick Brown Fox jumps over the lazy dog" - ); - assert_eq!( - wire_paragraphs(&imported.wire), - vec!["The Quick Brown Fox", "jumps over the lazy dog"] - ); - - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn native_legacy_import_converts_to_wire() { - let dir = std::env::temp_dir().join(format!("doc_import_test_legacy_{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("old.doc.json"); - std::fs::write(&path, "M|actor|0|0\nP|Left|Legacy body§false§false§false§12§~\n").unwrap(); - - let imported = import_document_from_path(&path).expect("import legacy"); - assert_eq!(imported.title, "Legacy body"); - assert!(imported.wire.starts_with(crate::projection_session::CRDT_SAVE_HEADER)); - assert_eq!(wire_paragraphs(&imported.wire), vec!["Legacy body"]); - - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn native_wire_import_passes_through() { - let dir = std::env::temp_dir().join(format!("doc_import_test_wire_{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let wire = build_wire_from_paragraphs(&["Saved doc".into()]).unwrap(); - let path = dir.join("saved.doc.json"); - std::fs::write(&path, &wire).unwrap(); - - let imported = import_document_from_path(&path).expect("import wire"); - assert_eq!(imported.wire, wire); - assert_eq!(imported.title, "Saved doc"); - - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn unsupported_format_is_rejected() { - let dir = std::env::temp_dir().join(format!("doc_import_test_bad_{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("notes.pdf"); - std::fs::write(&path, "%PDF-1.4 fake").unwrap(); - assert!(import_document_from_path(&path).is_err()); - std::fs::remove_dir_all(&dir).ok(); - } -} \ No newline at end of file diff --git a/crates/apps/doc/doc-ui/src/persistence.rs b/crates/apps/doc/doc-ui/src/persistence.rs deleted file mode 100644 index 9cba272..0000000 --- a/crates/apps/doc/doc-ui/src/persistence.rs +++ /dev/null @@ -1,193 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -const GENERATED_DIR: &str = "generated"; -pub(crate) const GENERATED_DOC_FILE: &str = "current.doc.json"; -pub(crate) const MAX_UNDO_LEVELS: usize = 100; - -/// Metadata about a saved document file for the dashboard. -#[derive(Clone, Debug)] -pub struct DocEntry { - pub filename: String, - pub path: PathBuf, - /// First non-empty paragraph, or the file stem when unavailable. - pub title: String, - /// Preview snippet of the document's body text. - pub preview: String, - /// File size in bytes. - pub size: u64, - /// Last modified time (seconds since UNIX_EPOCH), or 0 if unknown. - pub modified: u64, -} - -/// Root directory for doc state written at runtime. -/// -/// This used to be `env!("CARGO_MANIFEST_DIR")`, which bakes the **build -/// machine's** absolute source path into the shipped binary. On an Android -/// or iOS install that path does not exist, so the workspace's Open/Save -/// buttons silently did nothing (the CRDT editor additionally had no demo -/// fallback, so a fresh install booted to an empty document); and on a -/// developer machine the app wrote into its own source tree. -/// -/// `app_data_dir()` is the convention the rest of this crate already uses -/// (see `cad_store::cad_projects_dir` / `cad_persistence::cad_data_dir`). -/// Reads keep a one-way compatibility fallback to the old source-tree -/// location so existing developer saves are honored once; new saves only -/// ever go to the app data dir. -pub fn doc_store_dir() -> PathBuf { - nigig_core::dir::app_data_dir().join("nigig_build_store") -} - -/// Directory holding the runtime-saved document (`nigig_build_store/generated`). -pub fn doc_generated_dir_path() -> PathBuf { - doc_store_dir().join(GENERATED_DIR) -} - -/// The legacy save location inside the source tree (development checkouts -/// only): read as a fallback, never written. -fn dev_manifest_generated_dir_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_DIR) -} - -/// Load the last-saved document. Prefers the runtime store; falls back to -/// the legacy source-tree file for unreplicated developer saves. Returns -/// `None` when nothing exists or the file is empty. Split from the two -/// directory parameters so the migration logic is unit-testable against -/// temp dirs. -pub fn load_saved_doc_state() -> Option { - load_saved_doc_state_with(doc_generated_dir_path(), dev_manifest_generated_dir_path()) -} - -pub(crate) fn load_saved_doc_state_with( - store_dir: PathBuf, - manifest_dir: PathBuf, -) -> Option { - let read = |dir: &PathBuf| { - fs::read_to_string(dir.join(GENERATED_DOC_FILE)) - .ok() - .filter(|source| !source.trim().is_empty()) - }; - read(&store_dir).or_else(|| read(&manifest_dir)) -} - -/// Save the current document to the runtime store (never the source tree). -pub fn save_doc_state(data: &str) -> Result<(), String> { - save_doc_state_to(doc_generated_dir_path(), GENERATED_DOC_FILE, data) -} - -/// Save a copy under a caller-chosen filename in the runtime store. -pub fn save_doc_state_as(filename: &str, data: &str) -> Result<(), String> { - save_doc_state_to(doc_generated_dir_path(), filename, data) -} - -pub(crate) fn save_doc_state_to(dir: PathBuf, filename: &str, data: &str) -> Result<(), String> { - fs::create_dir_all(&dir) - .map_err(|err| format!("could not create generated directory: {err}"))?; - fs::write(dir.join(filename), data) - .map_err(|err| format!("could not save doc state: {err}"))?; - Ok(()) -} - -/// List saved `.doc.json` documents in the runtime store, sorted by -/// last-modified descending (most recent first). Each entry carries a -/// title and preview derived from the file's contents. Files whose names -/// are not plain filenames are skipped. -pub fn list_saved_docs() -> Vec { - let dir = doc_generated_dir_path(); - let Ok(entries) = fs::read_dir(&dir) else { - return Vec::new(); - }; - - let mut result = Vec::new(); - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().into_owned(); - if !validate_filename(&name) { - continue; - } - if !name.ends_with(".doc.json") { - continue; - } - let path = entry.path(); - let Ok(metadata) = entry.metadata() else { - continue; - }; - let preview = preview_doc_file(&path); - result.push(DocEntry { - filename: name, - path, - title: preview.title, - preview: preview.snippet, - size: metadata.len(), - modified: metadata - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0), - }); - } - - result.sort_by(|a, b| b.modified.cmp(&a.modified)); - result -} - -/// Lightweight preview of a saved document: first non-empty paragraph as -/// the title, a longer snippet as the preview. -struct DocPreview { - title: String, - snippet: String, -} - -fn preview_doc_file(path: &Path) -> DocPreview { - let fallback_title = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("Document") - .to_string(); - let Ok(contents) = fs::read_to_string(path) else { - return DocPreview { - title: fallback_title, - snippet: String::new(), - }; - }; - let paragraphs = crate::doc_import::plain_text_paragraphs(&contents); - title_and_snippet(¶graphs, fallback_title) -} - -fn title_and_snippet(paragraphs: &[String], fallback_title: String) -> DocPreview { - let title = paragraphs - .iter() - .find(|p| !p.trim().is_empty()) - .cloned() - .unwrap_or(fallback_title); - let mut snippet = String::new(); - for p in paragraphs { - let trimmed = p.trim(); - if trimmed.is_empty() { - continue; - } - if !snippet.is_empty() { - snippet.push(' '); - } - snippet.push_str(trimmed); - if snippet.chars().count() >= 160 { - snippet = snippet.chars().take(160).collect(); - snippet.push_str("…"); - break; - } - } - DocPreview { title, snippet } -} - -/// Reject anything that is not a plain filename: empty, path separator, -/// `.`/`..`, hidden files, NULs, or overlong names. -fn validate_filename(filename: &str) -> bool { - !filename.is_empty() - && filename.len() <= 255 - && filename != "." - && filename != ".." - && !filename.starts_with('.') - && !filename.contains('\0') - && !filename.contains('/') - && !filename.contains('\\') -} diff --git a/crates/apps/geohot/Cargo.toml b/crates/apps/geohot/Cargo.toml index 6de83b9..3171830 100644 --- a/crates/apps/geohot/Cargo.toml +++ b/crates/apps/geohot/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/makepad_table/Cargo.toml b/crates/apps/makepad_table/Cargo.toml index e6184ec..3371a33 100644 --- a/crates/apps/makepad_table/Cargo.toml +++ b/crates/apps/makepad_table/Cargo.toml @@ -14,7 +14,7 @@ license = "MIT OR Apache-2.0" # makepad from every other crate here. Verified to compile against the pin. # For local makepad dev, replace with: # makepad-widgets = { path = "../../widgets" } -makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" } # Image format sniffing for attached cells, matching what the Robrix-derived # app in this repo (`pageflipnav/src/utils.rs`) uses. Zero transitive diff --git a/crates/apps/makepad_table/apps/invoicer/Cargo.toml b/crates/apps/makepad_table/apps/invoicer/Cargo.toml index 1498881..cc00c80 100644 --- a/crates/apps/makepad_table/apps/invoicer/Cargo.toml +++ b/crates/apps/makepad_table/apps/invoicer/Cargo.toml @@ -7,7 +7,7 @@ description = "Makepad UI for editing invoices/quotes/receipts and exporting to license = "MIT OR Apache-2.0" [dependencies] -makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" } makepad-table = { path = "../.." } makepad-doc-model = { path = "../../crates/doc-model" } makepad-pdf-export = { path = "../../crates/pdf-export" } diff --git a/crates/apps/makepad_table/examples/table_demo/Cargo.toml b/crates/apps/makepad_table/examples/table_demo/Cargo.toml index ec1a1d7..164ba18 100644 --- a/crates/apps/makepad_table/examples/table_demo/Cargo.toml +++ b/crates/apps/makepad_table/examples/table_demo/Cargo.toml @@ -7,5 +7,5 @@ description = "Demo for the makepad-table widget" license = "MIT OR Apache-2.0" [dependencies] -makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251" } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" } makepad-table = { path = "../.." } diff --git a/crates/apps/map/Cargo.toml b/crates/apps/map/Cargo.toml index d0bff90..41f4b65 100644 --- a/crates/apps/map/Cargo.toml +++ b/crates/apps/map/Cargo.toml @@ -5,13 +5,14 @@ edition = "2021" description = "Map tile renderer with viewport, caching, scheduling, and MVT decoding" [dependencies] -makepad-widgets = { workspace = true } -makepad-draw = { workspace = true } -makepad-platform = { workspace = true } -makepad-derive-widget = { workspace = true } -makepad-fast-inflate = { workspace = true } -makepad-mbtile-reader = { workspace = true } -makepad-script = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" } +makepad-draw = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" } +makepad-platform = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" } +makepad-derive-widget = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" } +makepad-fast-inflate = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" } +makepad-mbtile-reader = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" } +makepad-script = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" } + # Polygon operations (used by makepad_map for advanced geometry) i_overlay = { version = "7.0.3", default-features = false } i_float = "1.0.0" @@ -19,7 +20,8 @@ i_shape = "1.0.0" i_tree = "0.19.0" [dev-dependencies] -makepad-test = { workspace = true } +makepad-test = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc" } + [features] default = ["map_style"] map_style = [] diff --git a/crates/apps/map/tests/makepad_test_app/Cargo.toml b/crates/apps/map/tests/makepad_test_app/Cargo.toml index c5699e5..1165957 100644 --- a/crates/apps/map/tests/makepad_test_app/Cargo.toml +++ b/crates/apps/map/tests/makepad_test_app/Cargo.toml @@ -6,9 +6,9 @@ description = "Visual regression test application for nigig-map widget" [dependencies] <<<<<<< HEAD -makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251", features = ["maps"] } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "5efe6e24c9f732e9f11b783757f196f4f1c402b2", features = ["maps"] } ======= -makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251", features = ["maps"] } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc", features = ["maps"] } >>>>>>> 71b5460 (chore: update makepad fork to latest upstream/dev (abd70f4)) nigig-map = { path = "../.." } diff --git a/crates/apps/nigig-ai/Cargo.toml b/crates/apps/nigig-ai/Cargo.toml index cab1824..b85847b 100644 --- a/crates/apps/nigig-ai/Cargo.toml +++ b/crates/apps/nigig-ai/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-ai/src/main.rs b/crates/apps/nigig-ai/src/main.rs index 0d1f0b0..d3f97aa 100644 --- a/crates/apps/nigig-ai/src/main.rs +++ b/crates/apps/nigig-ai/src/main.rs @@ -11,7 +11,8 @@ script_mod! { window.title: "nigig-ai" body +: { root := mod.widgets.StandaloneFeatureShell { - root_screen := mod.widgets.AIScreen {} + root_screen := mod.widgets.AIScreen {} + } standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav { root_nav := mod.widgets.AiActionBar {} } diff --git a/crates/apps/nigig-alerts/Cargo.toml b/crates/apps/nigig-alerts/Cargo.toml index 9c90c8d..5868c6a 100644 --- a/crates/apps/nigig-alerts/Cargo.toml +++ b/crates/apps/nigig-alerts/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-alerts/src/main.rs b/crates/apps/nigig-alerts/src/main.rs index 322b5ab..eef4b63 100644 --- a/crates/apps/nigig-alerts/src/main.rs +++ b/crates/apps/nigig-alerts/src/main.rs @@ -11,7 +11,8 @@ script_mod! { window.title: "nigig-alerts" body +: { root := mod.widgets.StandaloneFeatureShell { - root_screen := mod.widgets.AlertsScreen {} + root_screen := mod.widgets.AlertsScreen {} + } standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav { root_nav := mod.widgets.AlertsActionBar {} } diff --git a/crates/apps/nigig-book/Cargo.toml b/crates/apps/nigig-book/Cargo.toml index 0c4ad9c..6a52da8 100644 --- a/crates/apps/nigig-book/Cargo.toml +++ b/crates/apps/nigig-book/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-book/src/main.rs b/crates/apps/nigig-book/src/main.rs index 3e4ac3e..893455d 100644 --- a/crates/apps/nigig-book/src/main.rs +++ b/crates/apps/nigig-book/src/main.rs @@ -11,7 +11,8 @@ script_mod! { window.title: "nigig-book" body +: { root := mod.widgets.StandaloneFeatureShell { - root_screen := mod.widgets.BookingScreen {} + root_screen := mod.widgets.BookingScreen {} + } standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav { root_nav := mod.widgets.BookActionBar {} } diff --git a/crates/apps/nigig-build/Cargo.toml b/crates/apps/nigig-build/Cargo.toml index 1cde79c..6170ec2 100644 --- a/crates/apps/nigig-build/Cargo.toml +++ b/crates/apps/nigig-build/Cargo.toml @@ -4,11 +4,11 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true, features = ["test", "csg", "gltf"] } -makepad-code-editor = { workspace = true } -makepad-xr = { workspace = true } -makepad-ai = { workspace = true } -makepad-base64 = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc", features = ["test", "csg", "gltf"] } +makepad-code-editor = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} +makepad-xr = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} +makepad-ai = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} +makepad-base64 = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} # makepad-gltf: read-side GLB parser. Used for round-trip validation # of arch_gltf.rs output (write GLB → load with makepad_gltf → verify). nigig-core = { path = "../../nigig-core" } @@ -22,7 +22,6 @@ spreadsheet-ui = { path = "../../apps/spreadsheet/spreadsheet-ui" } printpdf = "0.7" time = "0.3" rayon = "1.12.0" -doc-ui = { path = "../doc/doc-ui" } +doc-engine = { path = "../doc/doc-engine" } [dev-dependencies] -makepad-test = { workspace = true } \ No newline at end of file diff --git a/crates/apps/nigig-build/src/cad_store.rs b/crates/apps/nigig-build/src/cad_store.rs index 3d67ec2..82a1563 100644 --- a/crates/apps/nigig-build/src/cad_store.rs +++ b/crates/apps/nigig-build/src/cad_store.rs @@ -3,7 +3,6 @@ use std::fs; use std::path::PathBuf; use crate::dir::app_data_dir; -use crate::project_store::{self, ProjectRecord}; pub const CAD_FILE_EXTENSION: &str = "cad"; @@ -29,48 +28,25 @@ pub fn clear_active_project() { ACTIVE_PROJECT.with(|slot| *slot.borrow_mut() = None); } -pub fn store_dir() -> PathBuf { - app_data_dir().join("nigig_build_store") -} - pub fn cad_projects_dir() -> PathBuf { - let dir = store_dir().join("cad"); + let dir = app_data_dir().join("nigig_build_store").join("cad"); if let Err(e) = fs::create_dir_all(&dir) { makepad_widgets::error!("cad_store: failed creating dir {:?}: {}", dir, e); } dir } -pub fn cad_projects_dir_in(dir: &std::path::Path) -> PathBuf { - dir.join("cad") -} - pub fn cad_file_path(project_id: &str) -> PathBuf { - cad_file_path_in(&store_dir(), project_id) -} - -fn cad_file_path_in(dir: &std::path::Path, project_id: &str) -> PathBuf { - cad_projects_dir_in(dir).join(format!("{}.{}", project_id, CAD_FILE_EXTENSION)) + cad_projects_dir().join(format!("{}.{}", project_id, CAD_FILE_EXTENSION)) } pub fn save_cad_script(project_id: &str, source: &str) -> Result<(), String> { - save_cad_script_in(&store_dir(), project_id, source) -} - -fn save_cad_script_in(dir: &std::path::Path, project_id: &str, source: &str) -> Result<(), String> { - let path = cad_file_path_in(dir, project_id); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("failed to create directory: {}", e))?; - } + let path = cad_file_path(project_id); fs::write(&path, source).map_err(|e| format!("failed to save CAD script: {}", e)) } pub fn load_cad_script(project_id: &str) -> Result { - load_cad_script_in(&store_dir(), project_id) -} - -fn load_cad_script_in(dir: &std::path::Path, project_id: &str) -> Result { - let path = cad_file_path_in(dir, project_id); + let path = cad_file_path(project_id); fs::read_to_string(&path).map_err(|e| format!("failed to load CAD script: {}", e)) } @@ -119,166 +95,3 @@ pub fn export_mesh_to_obj( Ok(()) } - -/// Return the projects that have a saved CAD script on disk, newest first. -/// -/// The dashboard shows these so the user can reopen a piece of work -/// instead of always starting from a blank script. A project is only -/// listed once its `.cad` file exists; a record without a script can -/// still be opened from the projects page and starts from the default. -pub fn list_cad_projects() -> Vec { - list_cad_projects_in(&store_dir()) -} - -fn load_projects_from(dir: &std::path::Path) -> Vec { - fs::read_to_string(dir.join("projects.json")) - .ok() - .and_then(|data| serde_json::from_str(&data).ok()) - .unwrap_or_default() -} - -fn list_cad_projects_in(dir: &std::path::Path) -> Vec { - let mut projects: Vec = load_projects_from(dir) - .into_iter() - .filter(|p| load_cad_script_in(dir, &p.id).is_ok()) - .collect(); - projects.sort_by(|a, b| b.created_at_ms.cmp(&a.created_at_ms)); - projects -} - -/// Create a CAD project: persist a record, leave a blank `.cad` script on -/// disk, and activate it. Returns the new record so the caller can hand -/// the editor a starting script (the default) without a second lookup. -pub fn create_cad_project(name: &str, project_type: &str, description: &str) -> ProjectRecord { - create_cad_project_in(&store_dir(), name, project_type, description) -} - -fn save_project_in_store(dir: &std::path::Path, project: ProjectRecord) { - let path = dir.join("projects.json"); - let mut projects = load_projects_from(dir); - if let Some(existing) = projects.iter_mut().find(|p| p.id == project.id) { - *existing = project.clone(); - } else { - projects.push(project.clone()); - } - projects.sort_by(|a, b| b.created_at_ms.cmp(&a.created_at_ms)); - let _ = fs::create_dir_all(dir); - if let Ok(data) = serde_json::to_string_pretty(&projects) { - let _ = fs::write(&path, data); - } -} - -fn create_cad_project_in(dir: &std::path::Path, name: &str, project_type: &str, description: &str) -> ProjectRecord { - let project = ProjectRecord { - id: format!("proj_{}", project_store::now_ms()), - name: name.trim().to_string(), - project_type: project_type.to_string(), - description: description.trim().to_string(), - created_at_ms: project_store::now_ms(), - }; - let _ = save_cad_script_in(dir, &project.id, ""); - save_project_in_store(dir, project.clone()); - set_active_project(ActiveProject { - id: project.id.clone(), - name: project.name.clone(), - }); - project -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use std::sync::atomic::{AtomicU64, Ordering}; - - static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); - - fn temp_dir() -> PathBuf { - let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); - let dir = std::env::temp_dir().join(format!("nigig_cad_test_{}_{}", project_store::now_ms(), id)); - fs::create_dir_all(&dir).unwrap(); - dir - } - - fn cleanup(dir: &PathBuf) { - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn list_cad_projects_only_returns_projects_with_script() { - let dir = temp_dir(); - let with_script = ProjectRecord { - id: "proj_with".to_string(), - name: "With Script".to_string(), - project_type: "CAM".to_string(), - description: String::new(), - created_at_ms: 1000, - }; - let no_script = ProjectRecord { - id: "proj_without".to_string(), - name: "No Script".to_string(), - project_type: "CAM".to_string(), - description: String::new(), - created_at_ms: 2000, - }; - save_project_in_store(&dir, with_script.clone()); - save_project_in_store(&dir, no_script.clone()); - save_cad_script_in(&dir, &with_script.id, "// a script").unwrap(); - - let listed = list_cad_projects_in(&dir); - assert!(listed.iter().any(|p| p.id == with_script.id)); - assert!(!listed.iter().any(|p| p.id == no_script.id)); - cleanup(&dir); - } - - #[test] - fn list_cad_projects_sorts_newest_first() { - let dir = temp_dir(); - let older = ProjectRecord { - id: "proj_old".to_string(), - name: "Older".to_string(), - project_type: "CAM".to_string(), - description: String::new(), - created_at_ms: 1000, - }; - let newer = ProjectRecord { - id: "proj_new".to_string(), - name: "Newer".to_string(), - project_type: "CAM".to_string(), - description: String::new(), - created_at_ms: 2000, - }; - save_project_in_store(&dir, older.clone()); - save_project_in_store(&dir, newer.clone()); - save_cad_script_in(&dir, &older.id, "").unwrap(); - save_cad_script_in(&dir, &newer.id, "").unwrap(); - - let listed = list_cad_projects_in(&dir); - assert_eq!(listed.len(), 2); - assert_eq!(listed[0].id, newer.id); - assert_eq!(listed[1].id, older.id); - cleanup(&dir); - } - - #[test] - fn create_cad_project_persists_record_script_and_activation() { - let dir = temp_dir(); - clear_active_project(); - let project = create_cad_project_in(&dir, " My Project ", "CAM", " test desc "); - - assert_eq!(project.name, "My Project"); - assert_eq!(project.description, "test desc"); - assert!(project.id.starts_with("proj_")); - - // The .cad script must exist on disk (blank) so it is listed. - assert!(load_cad_script_in(&dir, &project.id).is_ok()); - assert_eq!(list_cad_projects_in(&dir).len(), 1); - - // The project must have been activated. - let active = get_active_project().expect("active project should be set"); - assert_eq!(active.id, project.id); - - clear_active_project(); - cleanup(&dir); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/construction.rs b/crates/apps/nigig-build/src/construction_frame/construction.rs index a413f69..449469e 100644 --- a/crates/apps/nigig-build/src/construction_frame/construction.rs +++ b/crates/apps/nigig-build/src/construction_frame/construction.rs @@ -11,7 +11,6 @@ script_mod! { build_action_page_flip := PageFlip { width: Fill, height: Fill - lazy_init: true active_page: @projects_page projects_page := View { diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/bvh.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/bvh.rs deleted file mode 100644 index 3ef6137..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/bvh.rs +++ /dev/null @@ -1,800 +0,0 @@ -//! Binned-SAH binary BVH for O(log n) ray picking and spatial queries. -//! -//! Ported from `fab::model::bvh` and adapted to our f64 `TriMesh` / -//! `CadNode` types. The tree is rebuilt whenever the scene changes -//! (incremental refit is not worth the complexity below ~50k parts). -//! -//! Alongside the triangle tree the BVH keeps **per-element world bounds** -//! for linear frustum culling — element counts are in the hundreds or -//! thousands even when triangle counts are in the millions, so a linear -//! scan over element bounds is faster than a tree walk. - -use crate::construction_frame::pages::workspace::cad::cull::Frustum; -use crate::construction_frame::pages::workspace::cad::math::DVec3; -use crate::makepad_csg::TriMesh; -use makepad_widgets::makepad_math::*; -use std::collections::HashMap; - -/// Triangles per leaf. 8 is the sweet spot for architectural meshes. -pub const MAX_LEAF: usize = 8; -/// SAH bins per split axis. -const BINS: usize = 16; -/// Relative cost of a node traversal vs. one triangle test. -const TRAV_COST: f32 = 1.2; - -// ─── AABB helper ──────────────────────────────────────────────────────── - -#[derive(Clone, Copy, Debug)] -pub struct Aabb { - pub min: [f64; 3], - pub max: [f64; 3], -} - -impl Aabb { - pub fn empty() -> Self { - Self { - min: [f64::INFINITY; 3], - max: [f64::NEG_INFINITY; 3], - } - } - - pub fn is_empty(&self) -> bool { - self.min[0] > self.max[0] - } - - pub fn union_point(mut self, p: [f64; 3]) -> Self { - for i in 0..3 { - self.min[i] = self.min[i].min(p[i]); - self.max[i] = self.max[i].max(p[i]); - } - self - } - - pub fn union(mut self, other: &Aabb) -> Self { - for i in 0..3 { - self.min[i] = self.min[i].min(other.min[i]); - self.max[i] = self.max[i].max(other.max[i]); - } - self - } - - pub fn center(&self) -> [f64; 3] { - [ - (self.min[0] + self.max[0]) * 0.5, - (self.min[1] + self.max[1]) * 0.5, - (self.min[2] + self.max[2]) * 0.5, - ] - } - - pub fn extent(&self) -> [f64; 3] { - [ - self.max[0] - self.min[0], - self.max[1] - self.min[1], - self.max[2] - self.min[2], - ] - } - - pub fn surface(&self) -> f64 { - let e = self.extent(); - 2.0 * (e[0] * e[1] + e[1] * e[2] + e[2] * e[0]) - } -} - -// ─── Ray ──────────────────────────────────────────────────────────────── - -#[derive(Clone, Copy, Debug)] -pub struct BvhRay { - pub origin: DVec3, - pub dir: DVec3, - pub inv_dir: [f64; 3], -} - -impl BvhRay { - pub fn new(origin: DVec3, dir: DVec3) -> Self { - let inv_dir = [ - if dir.x.abs() < 1e-30 { f64::INFINITY } else { 1.0 / dir.x }, - if dir.y.abs() < 1e-30 { f64::INFINITY } else { 1.0 / dir.y }, - if dir.z.abs() < 1e-30 { f64::INFINITY } else { 1.0 / dir.z }, - ]; - Self { origin, dir, inv_dir } - } - - pub fn at(&self, t: f64) -> DVec3 { - DVec3 { - x: self.origin.x + self.dir.x * t, - y: self.origin.y + self.dir.y * t, - z: self.origin.z + self.dir.z * t, - } - } -} - -// ─── Hit result ───────────────────────────────────────────────────────── - -#[derive(Clone, Copy, Debug)] -pub struct BvhHit { - pub node_id: u64, - pub t: f64, - pub point: DVec3, -} - -// ─── Pick options ─────────────────────────────────────────────────────── - -pub struct BvhPickOptions<'a> { - pub visible: &'a dyn Fn(u64) -> bool, - pub max_t: f64, - pub cull_backfaces: bool, -} - -impl Default for BvhPickOptions<'_> { - fn default() -> Self { - Self { - visible: &|_| true, - max_t: f64::INFINITY, - cull_backfaces: false, - } - } -} - -// ─── BVH internals ───────────────────────────────────────────────────── - -#[derive(Clone, Copy, Debug)] -struct Prim { - node_id: u64, - tri_idx: u32, - bounds: Aabb, -} - -#[derive(Clone, Copy, Debug)] -struct Node { - min: [f64; 3], - max: [f64; 3], - first: u32, - count: u32, // 0 = interior, >0 = leaf -} - -// ─── BVH public API ──────────────────────────────────────────────────── - -pub struct Bvh { - nodes: Vec, - prims: Vec, - /// Per-node-id world bounds for linear frustum culling. - element_bounds: Vec<(u64, Aabb)>, - triangle_count: usize, -} - -impl Bvh { - /// Build a BVH from a list of (node_id, mesh, model_matrix) tuples. - /// - /// `model_matrix` transforms local mesh vertices to world space. - pub fn build( - parts: &[(u64, &TriMesh, &Mat4f)], - ) -> Self { - if parts.is_empty() { - return Self { - nodes: vec![], - prims: vec![], - element_bounds: vec![], - triangle_count: 0, - }; - } - - // 1. Flatten all triangles into primitives with per-triangle bounds. - let mut prims: Vec = Vec::new(); - let mut element_bounds_map: HashMap = HashMap::new(); - - for &(node_id, mesh, model) in parts { - let mut elem_aabb = Aabb::empty(); - for (tri_idx, tri) in mesh.triangles.iter().enumerate() { - let (v0, v1, v2) = mesh.triangle_vertices(tri_idx); - let w0 = mat4_mul_point(model, v0); - let w1 = mat4_mul_point(model, v1); - let w2 = mat4_mul_point(model, v2); - - let bounds = Aabb::empty() - .union_point(w0) - .union_point(w1) - .union_point(w2); - - prims.push(Prim { - node_id, - tri_idx: tri_idx as u32, - bounds, - }); - elem_aabb = elem_aabb.union(&bounds); - } - element_bounds_map - .entry(node_id) - .and_modify(|e| *e = e.union(&elem_aabb)) - .or_insert(elem_aabb); - } - - let mut element_bounds: Vec<(u64, Aabb)> = element_bounds_map.into_iter().collect(); - element_bounds.sort_by_key(|&(id, _)| id); - element_bounds.dedup_by_key(|&mut (id, _)| id); - - let total_tris = prims.len(); - if total_tris == 0 { - return Self { - nodes: vec![], - prims: vec![], - element_bounds, - triangle_count: 0, - }; - } - - // 2. Build the tree using a stack-based iterative builder. - let mut order: Vec = (0..total_tris as u32).collect(); - let mut nodes: Vec = Vec::with_capacity(total_tris); // upper bound - let mut stack: Vec<(u32, u32)> = Vec::with_capacity(48); // (start, count) - - // Root covers all primitives. - let root_bounds = compute_bounds(&prims, &order, 0, total_tris); - stack.push((0, total_tris as u32)); - - while let Some((start, count)) = stack.pop() { - if count <= MAX_LEAF as u32 { - let node_idx = nodes.len() as u32; - nodes.push(Node { - min: root_bounds.min, // placeholder, rewritten below - max: root_bounds.max, - first: start, - count, - }); - // Rewrite bounds for this leaf. - let bounds = compute_bounds(&prims, &order, start as usize, count as usize); - nodes[node_idx as usize].min = bounds.min; - nodes[node_idx as usize].max = bounds.max; - continue; - } - - // Try SAH split. - if let Some(split) = sah_split(&prims, &mut order, start as usize, count as usize) { - let left_count = (split - start as usize) as u32; - let right_count = count - left_count; - let left_bounds = compute_bounds(&prims, &order, start as usize, left_count as usize); - - // Reserve space for this interior node (will be filled after children). - let node_idx = nodes.len() as u32; - nodes.push(Node { - min: [0.0; 3], - max: [0.0; 3], - first: 0, - count: 0, - }); - - // Push right then left (left processed first = nearer in stack). - stack.push((start + left_count, right_count)); - stack.push((start, left_count)); - - // After both children are done, the node's bounds = union of children. - // We'll fix this with a post-pass. - // For now, compute from the full range. - let full_bounds = compute_bounds(&prims, &order, start as usize, count as usize); - nodes[node_idx as usize].min = full_bounds.min; - nodes[node_idx as usize].max = full_bounds.max; - nodes[node_idx as usize].first = node_idx + 1; // left child is next - } else { - // Can't split — make a leaf with everything. - let node_idx = nodes.len() as u32; - let bounds = compute_bounds(&prims, &order, start as usize, count as usize); - nodes.push(Node { - min: bounds.min, - max: bounds.max, - first: start, - count, - }); - } - } - - Self { - nodes, - prims, - element_bounds, - triangle_count: total_tris, - } - } - - pub fn triangle_count(&self) -> usize { - self.triangle_count - } - - pub fn node_count(&self) -> usize { - self.nodes.len() - } - - pub fn element_bounds(&self) -> &[(u64, Aabb)] { - &self.element_bounds - } - - /// Raycast: find the nearest hit. - pub fn raycast( - &self, - ray: &BvhRay, - opts: &BvhPickOptions<'_>, - triangle_at: impl Fn(u64, u32) -> (DVec3, DVec3, DVec3), - ) -> Option { - if self.nodes.is_empty() { - return None; - } - - let mut best_t = opts.max_t; - let mut best: Option = None; - let mut stack: Vec<(u32, f64)> = Vec::with_capacity(48); - stack.push((0, 0.0)); - - while let Some((ni, _t_entry)) = stack.pop() { - let node = &self.nodes[ni as usize]; - - // Skip nodes whose AABB is missed by the ray. - if slab_entry(node, ray).is_none() { - continue; - } - - if node.count > 0 { - // Leaf: test all triangles. - for p in &self.prims[node.first as usize..(node.first + node.count) as usize] { - if !(opts.visible)(p.node_id) { - continue; - } - let (v0, v1, v2) = triangle_at(p.node_id, p.tri_idx); - if let Some(t) = ray_triangle_test(ray, v0, v1, v2, opts.cull_backfaces) { - if t < best_t { - best_t = t; - best = Some(BvhHit { - node_id: p.node_id, - t, - point: ray.at(t), - }); - } - } - } - continue; - } - - // Interior: test both children. - let left = node.first as usize; - let right = left + 1; - let tl = slab_entry(&self.nodes[left], ray); - let tr = slab_entry(&self.nodes[right], ray); - - match (tl, tr) { - (Some(tl), Some(tr)) => { - if tl < tr { - stack.push((right as u32, tr)); - stack.push((left as u32, tl)); - } else { - stack.push((left as u32, tl)); - stack.push((right as u32, tr)); - } - } - (Some(t), None) => stack.push((left as u32, t)), - (None, Some(t)) => stack.push((right as u32, t)), - (None, None) => {} - } - } - - best - } - - /// Frustum cull: linear scan over element bounds (not tree-based). - pub fn frustum_elements(&self, frustum: &Frustum, out: &mut Vec) { - out.clear(); - for &(id, ref aabb) in &self.element_bounds { - if frustum_aabb_intersect(frustum, aabb) { - out.push(id); - } - } - } -} - -// ─── Internal helpers ─────────────────────────────────────────────────── - -fn compute_bounds(prims: &[Prim], order: &[u32], start: usize, count: usize) -> Aabb { - let mut bounds = Aabb::empty(); - for i in start..start + count { - bounds = bounds.union(&prims[order[i] as usize].bounds); - } - bounds -} - -fn sah_split(prims: &[Prim], order: &mut [u32], start: usize, count: usize) -> Option { - if count <= MAX_LEAF { - return None; // Not worth splitting. - } - - // Find the longest axis of the centroid bounding box. - let mut centroid_min = [f64::INFINITY; 3]; - let mut centroid_max = [f64::NEG_INFINITY; 3]; - for i in start..start + count { - let c = prims[order[i] as usize].bounds.center(); - for a in 0..3 { - centroid_min[a] = centroid_min[a].min(c[a]); - centroid_max[a] = centroid_max[a].max(c[a]); - } - } - let extent = [ - centroid_max[0] - centroid_min[0], - centroid_max[1] - centroid_min[1], - centroid_max[2] - centroid_min[2], - ]; - let axis = if extent[0] >= extent[1] && extent[0] >= extent[2] { - 0 - } else if extent[1] >= extent[2] { - 1 - } else { - 2 - }; - if extent[axis] < 1e-12 { - return None; // All centroids coincident. - } - - // Compute total surface area of all primitives in this range. - let total_surface: f64 = (start..start + count) - .map(|i| prims[order[i] as usize].bounds.surface()) - .sum(); - if total_surface <= 0.0 { - return None; - } - - // Bin centroids. - let bin_surface = vec![0.0f64; BINS]; - let bin_count = vec![0u32; BINS]; - let bin_bounds = vec![Aabb::empty(); BINS]; - let mut bin_surface = bin_surface; - let mut bin_count = bin_count; - let mut bin_bounds = bin_bounds; - - let scale = BINS as f64 / extent[axis]; - for i in start..start + count { - let c = prims[order[i] as usize].bounds.center(); - let bin = ((c[axis] - centroid_min[axis]) * scale) as usize; - let bin = bin.min(BINS - 1); - bin_surface[bin] += prims[order[i] as usize].bounds.surface(); - bin_count[bin] += 1; - bin_bounds[bin] = bin_bounds[bin].union(&prims[order[i] as usize].bounds); - } - - // Prefix sweep: cost of sending everything to the left of split. - let mut left_count = vec![0u32; BINS]; - let mut left_surface = vec![0.0f64; BINS]; - let mut left_bounds = vec![Aabb::empty(); BINS]; - left_count[0] = bin_count[0]; - left_surface[0] = bin_surface[0]; - left_bounds[0] = bin_bounds[0]; - for i in 1..BINS { - left_count[i] = left_count[i - 1] + bin_count[i]; - left_surface[i] = left_surface[i - 1] + bin_surface[i]; - left_bounds[i] = left_bounds[i - 1].union(&bin_bounds[i]); - } - - // Suffix: cost of sending everything to the right of split. - let mut right_count = vec![0u32; BINS]; - let mut right_surface = vec![0.0f64; BINS]; - let mut right_bounds = vec![Aabb::empty(); BINS]; - right_count[BINS - 1] = bin_count[BINS - 1]; - right_surface[BINS - 1] = bin_surface[BINS - 1]; - right_bounds[BINS - 1] = bin_bounds[BINS - 1]; - for i in (0..BINS - 1).rev() { - right_count[i] = right_count[i + 1] + bin_count[i]; - right_surface[i] = right_surface[i + 1] + bin_surface[i]; - right_bounds[i] = right_bounds[i + 1].union(&bin_bounds[i]); - } - - // Find best split. - let mut best_cost = f64::INFINITY; - let mut best_split = 0usize; - for i in 0..BINS - 1 { - if left_count[i] == 0 || right_count[i + 1] == 0 { - continue; - } - let cost = TRAV_COST as f64 - + (left_surface[i] * left_count[i] as f64 - + right_surface[i + 1] * right_count[i + 1] as f64) - / total_surface; - if cost < best_cost { - best_cost = cost; - best_split = i; - } - } - - // Compare against no-split cost. - let no_split_cost = count as f64; - if best_cost >= no_split_cost { - return None; - } - - // Partition around the split plane. - let split_pos = centroid_min[axis] - + (best_split as f64 + 0.5) / BINS as f64 * extent[axis]; - let mut left = start; - let mut right = start + count - 1; - while left <= right { - let c = prims[order[left] as usize].bounds.center(); - if c[axis] <= split_pos { - left += 1; - } else { - order.swap(left, right); - if right == 0 { - break; - } - right -= 1; - } - } - - if left == start || left == start + count { - return None; // All on one side. - } - Some(left) -} - -fn slab_entry(node: &Node, ray: &BvhRay) -> Option { - let origin = [ray.origin.x, ray.origin.y, ray.origin.z]; - let mut tmin = f64::NEG_INFINITY; - let mut tmax = f64::INFINITY; - for i in 0..3 { - if ray.inv_dir[i].is_infinite() { - if origin[i] < node.min[i] || origin[i] > node.max[i] { - return None; - } - } else { - let mut t1 = (node.min[i] - origin[i]) * ray.inv_dir[i]; - let mut t2 = (node.max[i] - origin[i]) * ray.inv_dir[i]; - if t1 > t2 { - std::mem::swap(&mut t1, &mut t2); - } - tmin = tmin.max(t1); - tmax = tmax.min(t2); - if tmin > tmax { - return None; - } - } - } - if tmax < 0.0 { - None - } else if tmin < 0.0 { - Some(tmax.max(0.0)) - } else { - Some(tmin) - } -} - -/// Moller-Trumbore ray-triangle intersection. Returns `t` if hit. -fn ray_triangle_test( - ray: &BvhRay, - v0: DVec3, - v1: DVec3, - v2: DVec3, - cull_backfaces: bool, -) -> Option { - let e1 = DVec3 { - x: v1.x - v0.x, - y: v1.y - v0.y, - z: v1.z - v0.z, - }; - let e2 = DVec3 { - x: v2.x - v0.x, - y: v2.y - v0.y, - z: v2.z - v0.z, - }; - let h = DVec3 { - x: ray.dir.y * e2.z - ray.dir.z * e2.y, - y: ray.dir.z * e2.x - ray.dir.x * e2.z, - z: ray.dir.x * e2.y - ray.dir.y * e2.x, - }; - let a = e1.x * h.x + e1.y * h.y + e1.z * h.z; - if a > -1e-12 && a < 1e-12 { - return None; - } - if cull_backfaces && a > 0.0 { - return None; - } - let f = 1.0 / a; - let s = DVec3 { - x: ray.origin.x - v0.x, - y: ray.origin.y - v0.y, - z: ray.origin.z - v0.z, - }; - let u = f * (s.x * h.x + s.y * h.y + s.z * h.z); - if u < 0.0 || u > 1.0 { - return None; - } - let q = DVec3 { - x: s.y * e1.z - s.z * e1.y, - y: s.z * e1.x - s.x * e1.z, - z: s.x * e1.y - s.y * e1.x, - }; - let v = f * (ray.dir.x * q.x + ray.dir.y * q.y + ray.dir.z * q.z); - if v < 0.0 || u + v > 1.0 { - return None; - } - let t = f * (e2.x * q.x + e2.y * q.y + e2.z * q.z); - if t > 1e-9 { - Some(t) - } else { - None - } -} - -/// Frustum-vs-AABB test using the p-vertex method. -fn frustum_aabb_intersect(frustum: &Frustum, aabb: &Aabb) -> bool { - for plane in &frustum.planes { - // Find the p-vertex (the corner most aligned with the plane normal). - let px = if plane[0] >= 0.0 { aabb.max[0] } else { aabb.min[0] }; - let py = if plane[1] >= 0.0 { aabb.max[1] } else { aabb.min[1] }; - let pz = if plane[2] >= 0.0 { aabb.max[2] } else { aabb.min[2] }; - let d = plane[0] * px + plane[1] * py + plane[2] * pz + plane[3]; - if d < 0.0 { - return false; - } - } - true -} - -/// Transform a point by a 4x4 matrix (same as `mat4_mul_vec4` with w=1). -fn mat4_mul_point(m: &Mat4f, p: crate::makepad_csg::Vec3d) -> [f64; 3] { - let v = [p.x as f32, p.y as f32, p.z as f32, 1.0f32]; - let r = [ - m.v[0] * v[0] + m.v[4] * v[1] + m.v[8] * v[2] + m.v[12] * v[3], - m.v[1] * v[0] + m.v[5] * v[1] + m.v[9] * v[2] + m.v[13] * v[3], - m.v[2] * v[0] + m.v[6] * v[1] + m.v[10] * v[2] + m.v[14] * v[3], - ]; - [r[0] as f64, r[1] as f64, r[2] as f64] -} - -#[cfg(test)] -mod tests { - use super::*; - - fn unit_cube_mesh() -> TriMesh { - let v = vec![ - crate::makepad_csg::Vec3d { x: 0.0, y: 0.0, z: 0.0 }, - crate::makepad_csg::Vec3d { x: 1.0, y: 0.0, z: 0.0 }, - crate::makepad_csg::Vec3d { x: 1.0, y: 1.0, z: 0.0 }, - crate::makepad_csg::Vec3d { x: 0.0, y: 1.0, z: 0.0 }, - crate::makepad_csg::Vec3d { x: 0.0, y: 0.0, z: 1.0 }, - crate::makepad_csg::Vec3d { x: 1.0, y: 0.0, z: 1.0 }, - crate::makepad_csg::Vec3d { x: 1.0, y: 1.0, z: 1.0 }, - crate::makepad_csg::Vec3d { x: 0.0, y: 1.0, z: 1.0 }, - ]; - let triangles = vec![ - [0, 1, 2], [0, 2, 3], // bottom - [4, 6, 5], [4, 7, 6], // top - [0, 4, 5], [0, 5, 1], // front - [2, 6, 7], [2, 7, 3], // back - [0, 3, 7], [0, 7, 4], // left - [1, 5, 6], [1, 6, 2], // right - ]; - TriMesh { vertices: v, triangles } - } - - #[test] - fn empty_bvh() { - let bvh = Bvh::build(&[]); - assert_eq!(bvh.triangle_count(), 0); - assert!(bvh.nodes.is_empty()); - } - - #[test] - fn single_mesh_build() { - let mesh = unit_cube_mesh(); - let id = 42u64; - let identity = Mat4f::identity(); - let bvh = Bvh::build(&[(id, &mesh, &identity)]); - assert_eq!(bvh.triangle_count(), 12); - assert!(!bvh.nodes.is_empty()); - assert!(!bvh.element_bounds.is_empty()); - } - - #[test] - fn raycast_hits_cube() { - let mesh = unit_cube_mesh(); - let id = 1u64; - let identity = Mat4f::identity(); - let bvh = Bvh::build(&[(id, &mesh, &identity)]); - - // Ray along +X toward the cube at (0.5, 0.5, 0.5). - let ray = BvhRay::new( - DVec3 { x: -1.0, y: 0.5, z: 0.5 }, - DVec3 { x: 1.0, y: 0.0, z: 0.0 }, - ); - let triangle_at = |node_id: u64, tri_idx: u32| -> (DVec3, DVec3, DVec3) { - assert_eq!(node_id, 1); - let (a, b, c) = mesh.triangle_vertices(tri_idx as usize); - (to_dvec3(a), to_dvec3(b), to_dvec3(c)) - }; - let hit = bvh.raycast(&ray, &BvhPickOptions::default(), triangle_at); - assert!(hit.is_some(), "ray should hit the cube"); - let hit = hit.unwrap(); - assert_eq!(hit.node_id, 1); - assert!((hit.t - 1.0).abs() < 1e-6, "expected t≈1.0, got {}", hit.t); - } - - #[test] - fn raycast_misses() { - let mesh = unit_cube_mesh(); - let id = 1u64; - let identity = Mat4f::identity(); - let bvh = Bvh::build(&[(id, &mesh, &identity)]); - - // Ray that misses the cube entirely. - let ray = BvhRay::new( - DVec3 { x: -1.0, y: 2.0, z: 0.5 }, - DVec3 { x: 1.0, y: 0.0, z: 0.0 }, - ); - let triangle_at = |_: u64, _: u32| -> (DVec3, DVec3, DVec3) { - unreachable!() - }; - let hit = bvh.raycast(&ray, &BvhPickOptions::default(), triangle_at); - assert!(hit.is_none(), "ray should miss"); - } - - fn translate_mat(tx: f32, ty: f32, tz: f32) -> Mat4f { - let mut m = Mat4f::identity(); - m.v[12] = tx; - m.v[13] = ty; - m.v[14] = tz; - m - } - - fn to_dvec3(p: crate::makepad_csg::Vec3d) -> DVec3 { - DVec3 { x: p.x, y: p.y, z: p.z } - } - - fn to_dvec3_arr(a: [f64; 3]) -> DVec3 { - DVec3 { x: a[0], y: a[1], z: a[2] } - } - - #[test] - fn multiple_meshes() { - let mesh = unit_cube_mesh(); - // Two cubes side by side. - let m1 = Mat4f::identity(); - let m2 = translate_mat(5.0, 0.0, 0.0); - let bvh = Bvh::build(&[(1, &mesh, &m1), (2, &mesh, &m2)]); - assert_eq!(bvh.triangle_count(), 24); - - // Ray hits first cube. - let ray = BvhRay::new( - DVec3 { x: -1.0, y: 0.5, z: 0.5 }, - DVec3 { x: 1.0, y: 0.0, z: 0.0 }, - ); - let triangle_at = |node_id: u64, tri_idx: u32| -> (DVec3, DVec3, DVec3) { - let m = if node_id == 1 { &m1 } else { &m2 }; - let (a, b, c) = mesh.triangle_vertices(tri_idx as usize); - ( - to_dvec3_arr(mat4_mul_point(m, a)), - to_dvec3_arr(mat4_mul_point(m, b)), - to_dvec3_arr(mat4_mul_point(m, c)), - ) - }; - let hit = bvh.raycast(&ray, &BvhPickOptions::default(), triangle_at); - assert!(hit.is_some()); - assert_eq!(hit.unwrap().node_id, 1); - } - - #[test] - fn aabb_basics() { - let a = Aabb::empty().union_point([0.0, 0.0, 0.0]).union_point([1.0, 2.0, 3.0]); - assert!(!a.is_empty()); - assert_eq!(a.center(), [0.5, 1.0, 1.5]); - assert_eq!(a.extent(), [1.0, 2.0, 3.0]); - } - - #[test] - fn slab_entry_basic() { - let node = Node { - min: [0.0, 0.0, 0.0], - max: [1.0, 1.0, 1.0], - first: 0, - count: 0, - }; - // Ray from (-1, 0.5, 0.5) in +X direction. - let ray = BvhRay::new( - DVec3 { x: -1.0, y: 0.5, z: 0.5 }, - DVec3 { x: 1.0, y: 0.0, z: 0.0 }, - ); - let t = slab_entry(&node, &ray); - assert!(t.is_some()); - assert!((t.unwrap() - 1.0).abs() < 1e-6); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/cad_scene.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/cad_scene.rs index 26b7ab6..cd383d8 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/cad_scene.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/cad_scene.rs @@ -986,23 +986,6 @@ impl CadNode { pub fn group_id(&self) -> Option { self.parent.map(|p| p.raw()) } - /// Whether this part is hidden from the viewport. Hidden parts are - /// skipped by picking, the BVH and both render passes. The flag is - /// stored as a `__hidden__` name prefix so it survives script round - /// trips and clone/snapshot without a parallel state array. - pub fn is_hidden(&self) -> bool { - self.name.starts_with("__hidden__") - } - /// Set or clear hidden state via the `__hidden__` name prefix. - pub fn set_hidden(&mut self, hidden: bool) { - if hidden { - if !self.name.starts_with("__hidden__") { - self.name = format!("__hidden__{}", self.name); - } - } else if let Some(stripped) = self.name.strip_prefix("__hidden__") { - self.name = stripped.to_string(); - } - } pub fn dof_constraint(&self) -> Option { self.metadata.dof_constraint } @@ -2385,26 +2368,6 @@ pub enum PartKind { Beam, } -impl PartKind { - pub fn label(self) -> &'static str { - match self { - Self::Cube => "Cube", - Self::Cylinder => "Cylinder", - Self::Sphere => "Sphere", - Self::Rect2D => "Rect2D", - Self::Circle2D => "Circle2D", - Self::Arc => "Arc", - Self::Polygon2D => "Polygon", - Self::Wall => "Wall", - Self::Slab => "Slab", - Self::Door => "Door", - Self::Window => "Window", - Self::Column => "Column", - Self::Beam => "Beam", - } - } -} - // =========================================================================== // Tests // =========================================================================== diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/camera_orbit.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/camera_orbit.rs deleted file mode 100644 index fca0fc3..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/camera_orbit.rs +++ /dev/null @@ -1,414 +0,0 @@ -//! Orbit camera math, pure functions over camera state. -//! -//! Ported from `fab::nav::orbit` and adapted to work alongside `XrCamera` -//! without modifying it. Orthographic state is tracked as separate `bool` -//! and `f32` fields on the viewport struct. -//! -//! Turntable is the default: the boom is re-derived from `eye - target` on -//! every step and the world up is re-asserted, so a mixed sequence of drags -//! can never accumulate roll. -//! -//! Dolly is *to the cursor*: the camera is uniformly scaled about a point on -//! the ray under the pointer, which leaves every point of that ray projecting -//! to exactly the same pixel. - -use makepad_widgets::makepad_math::*; -use makepad_xr::scene::XrCamera; - -/// Just short of the pole: a camera exactly on the axis has no defined yaw. -pub const PITCH_LIMIT: f32 = 1.5533; // 89° -pub const MIN_DISTANCE: f32 = 0.02; -pub const MAX_DISTANCE: f32 = 40_000.0; -pub const MIN_ORTHO_HEIGHT: f32 = 0.02; -pub const MAX_ORTHO_HEIGHT: f32 = 80_000.0; -pub const ORBIT_SENS: f32 = 0.0075; - -pub const WORLD_UP: Vec3f = Vec3f { - x: 0.0, - y: 0.0, - z: 1.0, -}; - -// ─── Pure math helpers ────────────────────────────────────────────────── - -pub fn rotate_about(v: Vec3f, axis: Vec3f, angle: f32) -> Vec3f { - let a = axis.normalize(); - if !a.is_finite() { - return v; - } - let s = angle.sin(); - let c = angle.cos(); - v * c + Vec3f::cross(a, v) * s + a * (a.dot(v) * (1.0 - c)) -} - -fn any_perpendicular(v: Vec3f) -> Vec3f { - let a = if v.x.abs() < 0.9 { - vec3(1.0, 0.0, 0.0) - } else { - vec3(0.0, 1.0, 0.0) - }; - Vec3f::cross(v, a).normalize() -} - -pub fn slerp(a: Vec3f, b: Vec3f, f: f32) -> Vec3f { - let a = a.normalize(); - let b = b.normalize(); - if !a.is_finite() || !b.is_finite() { - return b; - } - let d = a.dot(b).clamp(-1.0, 1.0); - if d > 0.9995 { - return Vec3f::from_lerp(a, b, f).normalize(); - } - if d < -0.9995 { - return rotate_about(a, any_perpendicular(a), std::f32::consts::PI * f); - } - let theta = d.acos(); - let st = theta.sin(); - a * (((1.0 - f) * theta).sin() / st) + b * ((f * theta).sin() / st) -} - -// ─── XrCamera helpers ─────────────────────────────────────────────────── - -pub fn forward(cam: &XrCamera) -> Vec3f { - let yaw = cam.orbit_yaw; - let pitch = cam.orbit_pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT); - vec3( - yaw.sin() * pitch.cos(), - pitch.sin(), - -yaw.cos() * pitch.cos(), - ) - .normalize() -} - -pub fn right(cam: &XrCamera) -> Vec3f { - let f = forward(cam); - Vec3f::cross(f, WORLD_UP).normalize() -} - -pub fn eye(cam: &XrCamera) -> Vec3f { - cam.desktop_target - forward(cam) * cam.distance -} - -// ─── Turntable ────────────────────────────────────────────────────────── - -pub fn turntable_angles(cam: &XrCamera) -> (f32, f32) { - let offset = eye(cam) - cam.desktop_target; - let dist = offset.length().max(1e-5); - // XrCamera convention: eye = target - forward * dist, - // forward = (sin(yaw)*cos(pitch), sin(pitch), -cos(yaw)*cos(pitch)) - // offset = -forward * dist = (-sin(yaw)*cos(pitch), -sin(pitch), cos(yaw)*cos(pitch)) * dist - let sin_pitch = (-offset.y / dist).clamp(-1.0, 1.0); - let pitch = sin_pitch.asin(); - let horiz = (offset.x * offset.x + offset.z * offset.z).sqrt(); - let yaw = if horiz > dist * 1e-3 { - f32::atan2(-offset.x, offset.z) - } else { - let s = if sin_pitch >= 0.0 { -1.0 } else { 1.0 }; - (s * WORLD_UP.y).atan2(s * WORLD_UP.x) - }; - (yaw, pitch) -} - -pub fn set_turntable(cam: &mut XrCamera, yaw: f32, pitch: f32, dist: f32) { - cam.orbit_yaw = yaw; - cam.orbit_pitch = pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT); - cam.distance = dist.clamp( - cam.distance_min.max(0.01), - cam.distance_max.max(cam.distance_min.max(0.01) + 0.01), - ); -} - -pub fn orbit_turntable(cam: &mut XrCamera, dx: f32, dy: f32) { - let (yaw, pitch) = turntable_angles(cam); - set_turntable(cam, yaw - dx * ORBIT_SENS, pitch + dy * ORBIT_SENS, cam.distance); -} - -pub fn orbit_trackball(cam: &mut XrCamera, dx: f32, dy: f32) { - let r = right(cam); - if !r.is_finite() { - orbit_turntable(cam, dx, dy); - return; - } - let mut offset = eye(cam) - cam.desktop_target; - let yaw_rot = -dx * ORBIT_SENS; - let pitch_rot = -dy * ORBIT_SENS; - offset = rotate_about(offset, WORLD_UP, yaw_rot); - offset = rotate_about(offset, r, pitch_rot); - let new_eye = cam.desktop_target + offset; - let new_offset = new_eye - cam.desktop_target; - let dist = new_offset.length().max(1e-5); - let sin_pitch = (-new_offset.y / dist).clamp(-1.0, 1.0); - let horiz = (new_offset.x * new_offset.x + new_offset.z * new_offset.z).sqrt(); - let new_yaw = if horiz > dist * 1e-3 { - f32::atan2(-new_offset.x, new_offset.z) - } else { - cam.orbit_yaw - }; - set_turntable(cam, new_yaw, sin_pitch.asin(), dist); -} - -// ─── Dolly ────────────────────────────────────────────────────────────── - -pub fn dolly( - cam: &mut XrCamera, - ortho: &mut bool, - ortho_height: &mut f32, - factor: f32, - anchor: Option, - fov_y: f32, -) { - if !factor.is_finite() || factor <= 0.0 { - return; - } - if *ortho { - let h = ortho_height.max(1e-4); - let f = factor.clamp(MIN_ORTHO_HEIGHT / h, MAX_ORTHO_HEIGHT / h); - *ortho_height = h * f; - if let Some(a) = anchor { - let fwd = forward(cam); - let v = eye(cam) - a; - let lateral = v - fwd * v.dot(fwd); - let shift = lateral * (f - 1.0); - if shift.is_finite() { - cam.desktop_target += shift; - } - } - } else { - let dist = cam.distance.max(1e-5); - let f = factor.clamp(MIN_DISTANCE / dist, MAX_DISTANCE / dist); - let a = anchor.unwrap_or(cam.desktop_target); - let current_eye = eye(cam); - let new_eye = a + (current_eye - a) * f; - let new_target = a + (cam.desktop_target - a) * f; - if new_eye.is_finite() && new_target.is_finite() { - cam.desktop_target = new_target; - let new_offset = new_eye - new_target; - let new_dist = new_offset.length(); - if new_dist > 1e-5 { - cam.distance = new_dist; - let sin_pitch = (-new_offset.y / new_dist).clamp(-1.0, 1.0); - let horiz = - (new_offset.x * new_offset.x + new_offset.z * new_offset.z).sqrt(); - if horiz > new_dist * 1e-3 { - cam.orbit_yaw = f32::atan2(-new_offset.x, new_offset.z); - } - cam.orbit_pitch = sin_pitch.asin(); - } - } - } - let _ = fov_y; // used only in ortho path implicitly via ortho_height -} - -pub fn pan(cam: &XrCamera, ortho: bool, ortho_height: f32, dx: f32, dy: f32, rect_h: f32, fov_y: f32) -> Vec3f { - let world_per_point = if ortho { - ortho_height / rect_h.max(1.0) - } else { - let half_fov = (fov_y.to_radians() * 0.5).max(1e-4); - 2.0 * cam.distance * half_fov.tan() / rect_h.max(1.0) - }; - let r = right(cam); - let f = forward(cam); - let up = Vec3f::cross(r, f).normalize(); - r * (-dx * world_per_point) + up * (dy * world_per_point) -} - -pub fn set_pivot(cam: &mut XrCamera, point: Vec3f) { - if !point.is_finite() { - return; - } - let dist = (eye(cam) - point).length(); - if !dist.is_finite() || dist < MIN_DISTANCE || dist > MAX_DISTANCE { - return; - } - cam.desktop_target = point; -} - -pub fn recenter(cam: &mut XrCamera, point: Vec3f) { - let shift = point - cam.desktop_target; - if shift.is_finite() { - cam.desktop_target = point; - } -} - -// ─── Projection toggle ────────────────────────────────────────────────── - -pub fn set_ortho( - cam: &mut XrCamera, - ortho: &mut bool, - ortho_height: &mut f32, - new_ortho: bool, - fov_y: f32, -) { - if *ortho == new_ortho { - return; - } - let half_fov = (fov_y.to_radians() * 0.5).max(1e-4); - if new_ortho { - *ortho_height = (2.0 * cam.distance * half_fov.tan()) - .clamp(MIN_ORTHO_HEIGHT, MAX_ORTHO_HEIGHT); - } else { - let d = (*ortho_height * 0.5 / half_fov.tan()) - .clamp(cam.distance_min.max(0.01), cam.distance_max); - let dir = forward(cam); - if dir.is_finite() { - cam.desktop_target = eye(cam) + dir * d; - cam.distance = d; - } - } - *ortho = new_ortho; -} - -// ─── Preset views ─────────────────────────────────────────────────────── - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PresetView { - Front, - Back, - Left, - Right, - Top, - Bottom, - Isometric, -} - -impl PresetView { - pub fn look_dir_and_up(self) -> (Vec3f, Vec3f) { - match self { - PresetView::Front => (vec3(0.0, -1.0, 0.0), WORLD_UP), - PresetView::Back => (vec3(0.0, 1.0, 0.0), WORLD_UP), - PresetView::Left => (vec3(-1.0, 0.0, 0.0), WORLD_UP), - PresetView::Right => (vec3(1.0, 0.0, 0.0), WORLD_UP), - PresetView::Top => (vec3(0.0, 0.0, -1.0), vec3(0.0, -1.0, 0.0)), - PresetView::Bottom => (vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0)), - PresetView::Isometric => ( - vec3(0.577, -0.577, 0.577).normalize(), - WORLD_UP, - ), - } - } -} - -pub fn apply_preset( - cam: &mut XrCamera, - ortho: &mut bool, - ortho_height: &mut f32, - preset: PresetView, - fov_y: f32, -) { - let (dir, _up) = preset.look_dir_and_up(); - let dist = cam - .distance - .clamp(cam.distance_min.max(0.01), cam.distance_max); - // Place eye along -dir from target (dir is the look direction). - let new_eye = cam.desktop_target - dir * dist; - let new_offset = new_eye - cam.desktop_target; - let new_dist = new_offset.length().max(1e-5); - // Recover yaw/pitch using XrCamera convention: - // offset = (-sin(yaw)*cos(pitch), -sin(pitch), cos(yaw)*cos(pitch)) * dist - let sin_pitch = (-new_offset.y / new_dist).clamp(-1.0, 1.0); - let horiz = - (new_offset.x * new_offset.x + new_offset.z * new_offset.z).sqrt(); - let new_yaw: f32 = if horiz > new_dist * 1e-3 { - f32::atan2(-new_offset.x, new_offset.z) - } else { - 0.0 - }; - cam.orbit_yaw = new_yaw; - cam.orbit_pitch = sin_pitch.asin(); - cam.distance = new_dist; - if preset != PresetView::Isometric { - *ortho = true; - let half_fov = (fov_y.to_radians() * 0.5).max(1e-4); - *ortho_height = (2.0 * dist * half_fov.tan()) - .clamp(MIN_ORTHO_HEIGHT, MAX_ORTHO_HEIGHT); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_cam() -> XrCamera { - let mut c = XrCamera::default(); - c.desktop_target = vec3(5.0, 3.5, 2.5); - c.orbit_yaw = 0.8; - c.orbit_pitch = 0.3; - c.distance = 20.0; - c - } - - #[test] - fn turntable_never_accumulates_roll() { - let mut cam = test_cam(); - let drags = [ - (37.0f32, -12.0f32), - (-90.0, 40.0), - (5.0, 300.0), - (250.0, -400.0), - (-3.0, 3.0), - ]; - for (dx, dy) in &drags { - orbit_turntable(&mut cam, *dx, *dy); - assert!(eye(&cam).is_finite(), "eye went non-finite"); - let r = right(&cam); - assert!(r.z.abs() < 1e-5, "roll crept in: right = {:?}", r); - } - } - - #[test] - fn turntable_survives_the_poles() { - let mut cam = test_cam(); - let mut ortho = false; - let mut ortho_h = 10.0; - let fov = cam.fov_y; - apply_preset(&mut cam, &mut ortho, &mut ortho_h, PresetView::Top, fov); - let before = forward(&cam); - orbit_turntable(&mut cam, 0.0, -1.0); - let after = forward(&cam); - assert!(after.is_finite()); - assert!( - after.dot(before) > 0.999, - "top view jumped: {before:?} -> {after:?}" - ); - } - - #[test] - fn preset_views_point_correctly() { - let cam = test_cam(); - for preset in [ - PresetView::Front, - PresetView::Back, - PresetView::Left, - PresetView::Right, - PresetView::Top, - PresetView::Bottom, - PresetView::Isometric, - ] { - let mut c = cam.clone(); - let mut ortho = false; - let mut ortho_h = 10.0; - let fov = cam.fov_y; - apply_preset(&mut c, &mut ortho, &mut ortho_h, preset, fov); - let (dir, _) = preset.look_dir_and_up(); - // PITCH_LIMIT prevents exactly reaching ±90°, so use 0.999. - assert!( - forward(&c).dot(dir) > 0.999, - "{preset:?}: {:?} vs {dir:?}", - forward(&c) - ); - assert!((c.distance - cam.distance).abs() < 1.0); - } - } - - #[test] - fn recenter_keeps_direction_and_distance() { - let mut cam = test_cam(); - let dir = forward(&cam); - let dist = cam.distance; - recenter(&mut cam, vec3(-2.0, 9.0, 1.0)); - assert!(forward(&cam).dot(dir) > 0.9999); - assert!((cam.distance - dist).abs() < 1e-4); - assert!((cam.desktop_target - vec3(-2.0, 9.0, 1.0)).length() < 1e-5); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/command_palette.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/command_palette.rs deleted file mode 100644 index 31d7d37..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/command_palette.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! Phase A — Command palette: fuzzy search over the commands the workspace can -//! actually run. The table here is the *inventory* of every verb the palette can -//! fire; each entry maps to an existing `CadWorkspace`/`CadViewport` handler so a -//! palette row can never be a dead end. -//! -//! The pure logic (scoring + ranking) lives here and is unit-tested; the overlay -//! wiring in `mod.rs`/`workspace.rs` dispatches a selected `CadCommand`. - -/// The closed set of verbs the command palette can run. Every variant maps to an -/// existing workspace/viewport handler — adding a variant here implies adding a -/// dispatch arm in `CadWorkspace::run_command`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CadCommand { - /// Frame the whole scene (fit). - FrameAll, - /// Frame the current selection. - FrameSelected, - /// Cycle render/shading mode. - CycleShading, - /// Toggle orthographic projection. - ToggleOrtho, - /// Go to a preset camera view. - ViewFront, - ViewRight, - ViewTop, - ViewIsometric, - /// Hide selected parts. - HideSelected, - /// Isolate the selected parts (hide everything else). - IsolateSelected, - /// Show all parts. - ShowAll, - /// Toggle the outliner panel. - ToggleOutliner, - /// Render the current scene at high resolution and save a PNG. - RenderImage, - /// Undo / redo the last command. - Undo, - Redo, -} - -impl CadCommand { - /// A short human label. - pub fn label(self) -> &'static str { - use CadCommand::*; - match self { - FrameAll => "Frame All", - FrameSelected => "Frame Selected", - CycleShading => "Shading: Next Mode", - ToggleOrtho => "Toggle Orthographic", - ViewFront => "View: Front", - ViewRight => "View: Right", - ViewTop => "View: Top", - ViewIsometric => "View: Isometric", - HideSelected => "Hide Selected", - IsolateSelected => "Isolate Selected", - ShowAll => "Show All", - ToggleOutliner => "Toggle Outliner", - RenderImage => "Render High-Res Image", - Undo => "Undo", - Redo => "Redo", - } - } - - /// Optional keyboard shortcut string shown in the palette row. - pub fn shortcut(self) -> &'static str { - use CadCommand::*; - match self { - FrameAll => "F", - FrameSelected => "F", - CycleShading => "", - ToggleOrtho => "", - ViewFront => "1", - ViewRight => "3", - ViewTop => "7", - ViewIsometric => "9", - HideSelected => "Ctrl+K", - IsolateSelected => "I", - ShowAll => "Ctrl+Shift+K", - ToggleOutliner => "List", - RenderImage => "F12", - Undo => "Ctrl+Z", - Redo => "Ctrl+Shift+Z", - } - } -} - -/// The full command inventory. Kept as a small groupable set matching what the -/// mobile toolbar already exposes, plus the viewport hotkeys, so the palette is -/// a discoverability surface (not new capability). -pub const COMMANDS: &[CadCommand] = &[ - CadCommand::FrameAll, - CadCommand::FrameSelected, - CadCommand::CycleShading, - CadCommand::ToggleOrtho, - CadCommand::ViewFront, - CadCommand::ViewRight, - CadCommand::ViewTop, - CadCommand::ViewIsometric, - CadCommand::HideSelected, - CadCommand::IsolateSelected, - CadCommand::ShowAll, - CadCommand::ToggleOutliner, - CadCommand::RenderImage, - CadCommand::Undo, - CadCommand::Redo, -]; - -/// Subsequence score: `None` when `needle` does not fit into `hay` in order. -/// Higher is better; consecutive runs and word starts score more. Ported verbatim -/// from fab's `ui/command_palette.rs::score`. -pub fn score(hay: &str, needle: &str) -> Option { - if needle.is_empty() { - return Some(0); - } - let h: Vec = hay.to_lowercase().chars().collect(); - let n: Vec = needle.to_lowercase().chars().collect(); - let mut hi = 0usize; - let mut total = 0i32; - let mut run = 0i32; - for c in n.iter() { - let mut found = None; - while hi < h.len() { - if h[hi] == *c { - found = Some(hi); - break; - } - hi += 1; - } - let at = found?; - let word_start = at == 0 || h[at - 1] == ' ' || h[at - 1] == ':'; - run = if run > 0 { run + 1 } else { 1 }; - total += 4 + run * 2 + if word_start { 6 } else { 0 } - (at as i32).min(12); - hi = at + 1; - } - Some(total) -} - -/// A ranked filter result: `(index into COMMANDS, score)`. -pub struct Match { - pub cmd: CadCommand, - pub score: i32, -} - -/// Return every command whose label subsequence-matches `query`, ranked best-first. -/// Also considers the shortcut string so "IZ" matches "Isolate Selected" (Ctrl+Z). -pub fn filter(query: &str) -> Vec { - let q = query.trim(); - let mut scored: Vec<(i32, usize)> = COMMANDS - .iter() - .enumerate() - .filter_map(|(i, c)| { - let (l, s) = (score(c.label(), q), score(c.shortcut(), q)); - l.or(s).map(|sc| (sc, i)) - }) - .collect(); - scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); - scored.into_iter().map(|(_, i)| COMMANDS[i]).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fuzzy_ranks_frame_all_first_for_fa() { - let hits = filter("fa"); - assert_eq!(hits.first(), Some(&CadCommand::FrameAll)); - } - - #[test] - fn fuzzy_ranks_frame_selected_over_shading_for_fs() { - let hits = filter("fs"); - assert_eq!(hits.first(), Some(&CadCommand::FrameSelected)); - } - - #[test] - fn no_subsequence_means_no_match() { - assert!(filter("zzz").is_empty()); - } - - #[test] - fn empty_query_returns_everything() { - let hits = filter(""); - assert_eq!(hits.len(), COMMANDS.len()); - } - - #[test] - fn shortcut_hits_count() { - // "cz" -> "Ctrl+Z" (Undo) via the shortcut string, even though the - // label "Undo" has no 'c'/'z' in that order. - let hits = filter("cz"); - assert!(hits.contains(&CadCommand::Undo)); - } - - #[test] - fn shorthand_iso_finds_isolate() { - assert_eq!(filter("iso").first(), Some(&CadCommand::IsolateSelected)); - } - - #[test] - fn view_top_beats_isolate_for_4() { - // Shortcut "7" -> Top, "9" -> Isometric, "1"/"3" front/right. - assert_eq!(filter("7").first(), Some(&CadCommand::ViewTop)); - } - - #[test] - fn case_insensitive() { - assert_eq!(filter("FRAME").first(), Some(&CadCommand::FrameAll)); - } - - #[test] - fn f12_shortcut_matches_render_image() { - assert_eq!(filter("F12").first(), Some(&CadCommand::RenderImage)); - // "render" also finds it by label. - assert_eq!(filter("render").first(), Some(&CadCommand::RenderImage)); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/commands.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/commands.rs index cf7b398..6f40766 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/commands.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/commands.rs @@ -621,20 +621,12 @@ impl CommandContext for CadCommandCtx<'_> { node: crate::construction_frame::pages::workspace::cad::cad_scene::CadNode, ) -> Result { let id = node.id; - // Node ids must be unique. A duplicate id means the caller already - // pushed the node (the add-part flow pushes into `parts` and then - // runs a CreateNode command). Keeping the first instance avoids - // silently doubling it — two nodes sharing one id would leave a - // ghost at the original grid slot after a move, because move/edit - // resolve only the first match by id. - if !self.parts.iter().any(|p| p.id == id) { - self.parts.push(node); - } - // A new node has no cache entry to invalidate and cannot affect any - // other node's mesh. Invalidating everything here would evict live - // entries to make room for nothing. + self.parts.push(node); self.scene_cache.mark_dirty(); self.invalidate_snapshot(); + // A new node has no cache entry to invalidate and cannot affect + // any other node's mesh. The clear that used to be here was + // evicting every live entry to make room for nothing. Ok(id) } @@ -2235,32 +2227,6 @@ mod real_context_tests { } assert_eq!(store.iter().count(), 0); } - - /// `create_node` must not create a duplicate when a node with the same - /// id is already present (the add-part flow pushes into `parts` and then - /// runs a CreateNode command). Duplicate ids would leave a ghost at the - /// grid slot after a move, because move/edit resolve only the first match. - /// Undo must still remove the single instance cleanly. - #[test] - fn create_node_skips_duplicate_ids() { - let mut store = PartsStore::new(); - let cache = SceneCache::new(); - store.push(node(5)); - let command = CreateNode { - node: node(5), - assigned_id: Some(NodeId(5)), - }; - { - let mut ctx = CadCommandCtx::new(&mut store, &cache); - command.execute(&mut ctx).expect("create succeeds"); - } - assert_eq!(store.iter().count(), 1, "duplicate id must not be pushed"); - { - let mut ctx = CadCommandCtx::new(&mut store, &cache); - command.undo(&mut ctx).expect("undo succeeds"); - } - assert_eq!(store.iter().count(), 0); - } } #[cfg(test)] diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/dashboard.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/dashboard.rs deleted file mode 100644 index d88f796..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/dashboard.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! `CadDashboard` widget: the project-file grid shown on first launch -//! of the CAD workspace (and from it, before a project is opened). -//! -//! Mirrors the `SpreadsheetDashboard`/`DocDashboard` pattern: a list of -//! saved projects in a grid of cards, a "+ New" button in the header, -//! and click-to-open. The workspace owns the `show_dashboard` flag; on -//! `NewProject`/`OpenProject` it hides itself an loads the editor. - -use makepad_widgets::makepad_platform::event::TouchState; -use makepad_widgets::*; - -use crate::cad_store; -use crate::project_store::ProjectRecord; - -/// Emitted to the workspace when the dashboard wants to switch views. -#[derive(Clone, Debug)] -pub enum CadAction { - /// Create a new blank project and open it. - NewProject, - /// Open an existing project by id. - OpenProject(String), - /// Return to the dashboard from the editor. - BackToDashboard, -} - -#[derive(Script, ScriptHook, Widget)] -pub struct CadDashboard { - #[deref] - view: View, - #[rust] - projects: Vec, - #[rust] - pub action: Option, - #[rust] - initialized: bool, - - // --- Draw resources for the project-card grid (manual rendering) --- - #[live] - draw_card_bg: DrawColor, - #[live] - draw_card_text: DrawText, - #[live] - draw_card_sub: DrawText, - #[live] - card_normal_color: Vec4f, - #[live] - card_hover_color: Vec4f, - #[live] - card_text_color: Vec4f, - #[live] - card_sub_color: Vec4f, - - /// Hit-test areas for each project card. - #[rust] - card_areas: Vec<(usize, Rect)>, - /// The dashboard's rect, stored during draw_walk for hit-testing. - #[rust] - rect: Rect, - /// Index of the card currently under the cursor. - #[rust] - hover_card: Option, -} - -impl CadDashboard { - pub fn set_dash_visible(&mut self, cx: &mut Cx, visible: bool) { - self.view.set_visible(cx, visible); - } - - /// Refresh the project listing from disk. - pub fn refresh_files(&mut self) { - self.projects = cad_store::list_cad_projects(); - self.card_areas.clear(); - } - - /// Called by the workspace when it becomes visible. - pub fn refresh_and_redraw(&mut self, cx: &mut Cx) { - self.projects = cad_store::list_cad_projects(); - self.card_areas.clear(); - self.view.redraw(cx); - } - - /// Draw project cards onto the canvas. - fn draw_cards(&mut self, cx: &mut Cx2d) { - self.card_areas.clear(); - - let area = self.view.area().rect(cx); - let card_w = 240.0_f64; - let card_h = 100.0_f64; - let margin_x = 16.0_f64; - let margin_y = 80.0_f64; - let spacing_x = 20.0_f64; - let spacing_y = 16.0_f64; - - let cols = - ((area.size.x - margin_x * 2.0 + spacing_x) / (card_w + spacing_x)).max(1.0) as usize; - - let mut col = 0usize; - let mut row = 0usize; - - for (i, entry) in self.projects.iter().enumerate() { - let x = area.pos.x + margin_x + col as f64 * (card_w + spacing_x); - let y = area.pos.y + margin_y + row as f64 * (card_h + spacing_y); - - let card_rect = Rect { - pos: DVec2 { x, y }, - size: DVec2 { - x: card_w, - y: card_h, - }, - }; - - let is_hovered = self.hover_card == Some(i); - self.draw_card_bg.color = if is_hovered { - self.card_hover_color - } else { - self.card_normal_color - }; - self.draw_card_bg.draw_abs(cx, card_rect); - - self.draw_card_text.color = self.card_text_color; - self.draw_card_text.draw_abs( - cx, - DVec2 { - x: card_rect.pos.x + 12.0, - y: card_rect.pos.y + 12.0, - }, - &entry.name, - ); - - self.draw_card_sub.color = self.card_sub_color; - self.draw_card_sub.draw_abs( - cx, - DVec2 { - x: card_rect.pos.x + 12.0, - y: card_rect.pos.y + 40.0, - }, - &entry.project_type, - ); - - self.draw_card_sub.draw_abs( - cx, - DVec2 { - x: card_rect.pos.x + 12.0, - y: card_rect.pos.y + 58.0, - }, - &entry.description, - ); - - self.card_areas.push((i, card_rect)); - - col += 1; - if col >= cols { - col = 0; - row += 1; - } - } - } - - /// Handle clicks on project cards. - fn handle_card_clicks(&mut self, cx: &mut Cx, event: &Event) { - if let Hit::FingerMove(fme) = event.hits_with_capture_overload(cx, self.draw_bg.area(), true) { - let pos = fme.abs; - let new_hover = self - .card_areas - .iter() - .find(|(_, rect)| rect.contains(pos)) - .map(|(i, _)| *i); - if new_hover != self.hover_card { - self.hover_card = new_hover; - self.view.redraw(cx); - } - } - - if let Event::TouchUpdate(tu) = event { - for touch in &tu.touches { - if touch.state == TouchState::Stop { - for &(idx, rect) in &self.card_areas { - if rect.contains(touch.abs) { - let id = self.projects[idx].id.clone(); - self.action = Some(CadAction::OpenProject(id)); - return; - } - } - } - } - } - - if let Hit::FingerUp(fe) = event.hits_with_capture_overload(cx, self.draw_bg.area(), true) { - if fe.is_primary_hit() { - for &(idx, rect) in &self.card_areas { - if rect.contains(fe.abs) { - let id = self.projects[idx].id.clone(); - self.action = Some(CadAction::OpenProject(id)); - return; - } - } - } - } - } -} - -impl Widget for CadDashboard { - fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - self.view.handle_event(cx, event, scope); - - if let Event::Actions(actions) = event { - self.handle_actions(cx, actions, scope); - } - - self.handle_card_clicks(cx, event); - } - - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - if !self.initialized { - self.refresh_files(); - self.initialized = true; - } - - let draw_step = self.view.draw_walk(cx, scope, walk); - - self.rect = self.view.area().rect(cx); - - if !self.projects.is_empty() { - self.draw_cards(cx); - } - - draw_step - } -} - -impl WidgetMatchEvent for CadDashboard { - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { - if self.button(cx, ids!(new_project_btn)).clicked(actions) { - self.action = Some(CadAction::NewProject); - self.view.redraw(cx); - } - - if self.button(cx, ids!(refresh_btn)).clicked(actions) { - self.refresh_and_redraw(cx); - } - } -} - -script_mod! { - use mod.prelude.widgets.* - - mod.widgets.CadDashboard = #(CadDashboard::register_widget(vm)) { - width: Fill, height: Fill, flow: Down - draw_bg +: { color: #x0a0f14 } - - dashboard_header := View { - width: Fill, height: 60.0, flow: Right - padding: Inset{left: 20.0, right: 20.0, top: 0, bottom: 0}, spacing: 12.0, align: Align{y: 0.5} - draw_bg +: { color: #x111820 } - - dashboard_title := Label { - text: "CAD Projects" - draw_text +: { color: #xf3f6f8, text_style: theme.font_bold { font_size: 18.0 } } - } - spacer := View { width: Fill } - refresh_btn := Button { - text: "Refresh", - width: 84.0, height: 32.0 - draw_bg +: { color: #x213040 } - draw_text +: { color: #xd8d8e8, text_style +: { font_size: 11.0 } } - } - new_project_btn := Button { - text: "+ New Project", - width: 112.0, height: 32.0 - draw_bg +: { color: #x238636 } - draw_text +: { color: #xffffff, text_style +: { font_size: 11.0 } } - } - } - - cards_container := View { - width: Fill, height: Fill - } - - // Manual draw resources for project cards - draw_card_bg +: { draw_depth: 0.1 } - draw_card_text +: { draw_depth: 0.3 color: #xf3f6f8 text_style: theme.font_bold { font_size: 14.0 } } - draw_card_sub +: { draw_depth: 0.3 color: #x8a8aa5 text_style: theme.font_regular { font_size: 11.0 } } - card_normal_color: #x171d24 - card_hover_color: #x22303c - card_text_color: #xf3f6f8 - card_sub_color: #x8a8aa5 - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/drag_num.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/drag_num.rs deleted file mode 100644 index 868bf5f..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/drag_num.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Pure drag-to-edit math for numeric fields, ported from fab's -//! `header_drag_math`. Kept free of makepad/widget types so it can be -//! unit-tested in isolation. -//! -//! The model: a pointer drag in *pixels* maps to a *count* of steps, and the -//! value is the anchor plus that many `step` multiples. Holding the fine -//! modifier (Ctrl) makes each pixel move a fraction of a step; the normal -//! modifier (the absence of fine) moves whole steps per pixel-equivalent. - -/// Map a raw pixel drag onto a value given an anchor, a pixel-per-step -/// sensitivity and a step (unit increment), optionally in fine/ctrl mode. -/// -/// * `anchor` — the starting value before the drag. -/// * `pixels` — total pointer travel in *drag pixels* since the anchor was -/// captured (positive = right/down, negative = left/up). -/// * `px_per_step` — how many drag pixels map to one step. -/// * `step` — the unit increment applied per step. -/// * `fine` — Ctrl held: keep fractional steps (continuous); otherwise snap to -/// whole steps for a grabbier, stepped feel. -pub fn header_drag_math( - anchor: f64, - pixels: f64, - px_per_step: f64, - step: f64, - fine: bool, -) -> f64 { - let pps = if px_per_step.abs() > 1e-9 { - px_per_step - } else { - 1.0 - }; - let st = if step.abs() > 1e-9 { step } else { 1.0 }; - let raw_steps = pixels / pps; - let steps = if fine { raw_steps } else { raw_steps.round() }; - anchor + steps * st -} - -/// Bind the value to a step grid (used when the field snaps while dragging). -pub fn snap_to_step(value: f64, step: f64) -> f64 { - if step.abs() < 1e-9 { - return value; - } - (value / step).round() * step -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn zero_drag_returns_anchor() { - assert_eq!(header_drag_math(10.0, 0.0, 10.0, 1.0, false), 10.0); - } - - #[test] - fn whole_steps_move_by_step() { - // 20 px at 10 px/unit = 2 units, step 1 → +2. - assert_eq!(header_drag_math(10.0, 20.0, 10.0, 1.0, false), 12.0); - } - - #[test] - fn negative_drag_decreases() { - assert_eq!(header_drag_math(10.0, -20.0, 10.0, 1.0, false), 8.0); - } - - #[test] - fn fine_mode_keeps_fractional_steps() { - // 4 px at 10 px/step = 0.4 steps. Normal snaps to 0; fine keeps 0.4. - let f = header_drag_math(0.0, 4.0, 10.0, 1.0, true); - assert!((f - 0.4).abs() < 1e-9, "fine delta was {f}"); - let n = header_drag_math(0.0, 4.0, 10.0, 1.0, false); - assert_eq!(n, 0.0); - } - - #[test] - fn normal_mode_snaps_to_whole_steps() { - // 25 px at 10 px/step = 2.5 steps → snaps to 3 whole steps. - assert_eq!(header_drag_math(0.0, 25.0, 10.0, 1.0, false), 3.0); - } - - #[test] - fn step_scales_delta() { - // step 2 → 2 whole steps × 2 = +4. - assert_eq!(header_drag_math(0.0, 20.0, 10.0, 2.0, false), 4.0); - } - - #[test] - fn degenerate_px_per_unit_does_not_crash() { - assert!(header_drag_math(5.0, 3.0, 0.0, 1.0, false).is_finite()); - } - - #[test] - fn snap_to_step_rounds() { - assert_eq!(snap_to_step(10.6, 1.0), 11.0); - assert_eq!(snap_to_step(10.3, 1.0), 10.0); - assert_eq!(snap_to_step(10.0, 0.0), 10.0); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/explode.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/explode.rs deleted file mode 100644 index 33fa7a7..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/explode.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Explode view: push parts radially apart so an assembled model reads as -//! discrete elements. -//! -//! Our parts have no storey grouping by default, so we support the -//! **by-element** mode: every part fans out in the ground (XZ) plane, keyed -//! by its document index, by `amount` per index step. Element 0 stays put. -//! Pure logic with no makepad types so it is unit-testable. - -/// How the explode spreads parts. Only by-element is supported today. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExplodeMode { - /// Radial fan-out in the ground plane, one element per part index. - ByElement, -} - -/// Aggregated explode controls held on the viewport. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct ExplodeState { - /// How far each index step fans out, in world units. `0.0` disables. - pub amount: f64, -} - -impl Default for ExplodeState { - fn default() -> Self { - ExplodeState { amount: 0.0 } - } -} - -/// The golden-angle (radians) used to distribute elements so no two radial -/// spokes coincide; ~137.508°. -const GOLDEN_ANGLE: f64 = 2.399963229728653; - -/// Explode displacement for the part at `id_idx` (its document order). -/// -/// Element 0 and any `amount <= 0` return a zero displacement. Each later -/// element fans out `amount * id_idx` along a direction derived from its -/// index (golden-angle), so elements spread evenly around the ground plane -/// without overlapping. `centre` is accepted for signature compatibility -/// with fab's radial rule; for by-element fan-out the direction is purely -/// index-derived, so the pivot is fixed at the origin. -pub fn element_offset(id_idx: usize, _centre: (f64, f64, f64), amount: f64) -> (f64, f64, f64) { - if amount <= 0.0 || id_idx == 0 { - return (0.0, 0.0, 0.0); - } - let angle = id_idx as f64 * GOLDEN_ANGLE; - let r = amount * id_idx as f64; - (angle.cos() * r, 0.0, angle.sin() * r) -} - -/// Helper used by the viewport: turn a document row index into a tripled -/// displacement the caller adds to the part's translation. Returns the -/// golden-angle fan-out for `state`. -pub fn displacement_for(id_idx: usize, state: &ExplodeState) -> (f64, f64, f64) { - element_offset(id_idx, (0.0, 0.0, 0.0), state.amount) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn element_zero_stays_put() { - let c = (5.0, 5.0, 5.0); - assert_eq!(element_offset(0, c, 3.0), (0.0, 0.0, 0.0)); - } - - #[test] - fn zero_amount_disables() { - let c = (0.0, 0.0, 0.0); - assert_eq!(element_offset(4, c, 0.0), (0.0, 0.0, 0.0)); - assert_eq!(displacement_for(4, &ExplodeState { amount: 0.0 }), (0.0, 0.0, 0.0)); - } - - #[test] - fn offset_magnitude_scales_with_index() { - let c = (0.0, 0.0, 0.0); - for i in 1..5 { - let (dx, dy, dz) = element_offset(i, c, 2.0); - let mag = (dx * dx + dz * dz).sqrt(); - assert!((mag - 2.0 * i as f64).abs() < 1e-9, "element {i} mag {mag}"); - } - } - - #[test] - fn radial_directions_differ() { - let c = (0.0, 0.0, 0.0); - let a = element_offset(1, c, 1.0); - let b = element_offset(2, c, 1.0); - let (ax, _, az) = a; - let (bx, _, bz) = b; - let am = (ax * ax + az * az).sqrt(); - let bm = (bx * bx + bz * bz).sqrt(); - // Normalise so the dot product is the cosine of the angle between - // the two spokes, not scaled by the per-element radii. - let dot = (ax / am) * (bx / bm) + (az / am) * (bz / bm); - assert!(dot.abs() < 1.0 - 1e-6); - assert_ne!(a, b); - } - - #[test] - fn displacement_stays_in_ground_plane() { - let c = (0.0, 0.0, 0.0); - assert_eq!(element_offset(3, c, 4.0).1, 0.0); - } - - #[test] - fn default_state_offs() { - assert_eq!(ExplodeState::default().amount, 0.0); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/keymap.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/keymap.rs deleted file mode 100644 index 1e26e43..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/keymap.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! Phase A(4) — F1 keymap help: the single source of truth for every keyboard -//! shortcut in the CAD workspace. The keymap is a closed, well-formed table; -//! the F1 help panel renders straight from `BINDINGS`, so the table can never -//! drift from what the panel shows. -//! -//! The pure logic (group/format/validate) lives here and is unit-tested; the -//! overlay wiring in `mod.rs`/`workspace.rs` opens the panel and fills a label -//! from `render_groups()`. - -/// One shortcut row. `keys` is the display chord (e.g. "Cmd+K", "Alt+H"), -/// `action` is what it does, and `group` buckets rows for the help panel. -pub struct KeyBinding { - /// Human-readable key chord, e.g. `"Cmd+K"`. - pub keys: &'static str, - /// What the shortcut does, e.g. `"Hide selected parts"`. - pub action: &'static str, - /// Section header this row belongs under in the help panel. - pub group: &'static str, -} - -/// Group headers, in display order. Rows whose `group` is not listed here are -/// still rendered, appended after every known group in table order. -pub const GROUPS: &[&'static str] = &[ - "Tools", - "Select & Visibility", - "Camera", - "Display", - "Edit", - "Render & UI", -]; - -/// The authoritative shortcut table. Adding/removing a shortcut here updates -/// the F1 help panel automatically — there is no second copy to keep in sync. -pub const BINDINGS: &[KeyBinding] = &[ - // --- Tools (no modifier) --- - KeyBinding { keys: "V", action: "Select tool", group: "Tools" }, - KeyBinding { keys: "L", action: "Line tool", group: "Tools" }, - KeyBinding { keys: "R", action: "Rect tool", group: "Tools" }, - KeyBinding { keys: "C", action: "Circle tool", group: "Tools" }, - KeyBinding { keys: "P", action: "Polyline tool", group: "Tools" }, - KeyBinding { keys: "W", action: "Wall tool", group: "Tools" }, - KeyBinding { keys: "O", action: "Column tool", group: "Tools" }, - KeyBinding { keys: "B", action: "Beam tool", group: "Tools" }, - KeyBinding { keys: "A", action: "Arc tool", group: "Tools" }, - KeyBinding { keys: "E", action: "Area tool", group: "Tools" }, - KeyBinding { keys: "Q", action: "Quad tool", group: "Tools" }, - KeyBinding { keys: "Y", action: "Polygon tool", group: "Tools" }, - KeyBinding { keys: "T", action: "Tri-plane tool", group: "Tools" }, - KeyBinding { keys: "U", action: "Extend tool", group: "Tools" }, - KeyBinding { keys: "H", action: "Chamfer tool", group: "Tools" }, - KeyBinding { keys: "M", action: "Measure tool", group: "Tools" }, - // --- Select & Visibility --- - KeyBinding { keys: "Cmd+K", action: "Hide selected parts", group: "Select & Visibility" }, - KeyBinding { keys: "Cmd+Shift+K", action: "Show all parts", group: "Select & Visibility" }, - KeyBinding { keys: "I", action: "Isolate selected parts", group: "Select & Visibility" }, - KeyBinding { keys: "Alt+H", action: "Hide/unhide all parts", group: "Select & Visibility" }, - // --- Camera --- - KeyBinding { keys: "F", action: "Frame all (zoom to fit)", group: "Camera" }, - KeyBinding { keys: "F5", action: "Toggle orthographic", group: "Camera" }, - KeyBinding { keys: "Alt+1", action: "View front", group: "Camera" }, - KeyBinding { keys: "Alt+2", action: "View back", group: "Camera" }, - KeyBinding { keys: "Alt+3", action: "View left", group: "Camera" }, - KeyBinding { keys: "Alt+4", action: "View right", group: "Camera" }, - KeyBinding { keys: "Alt+6", action: "View top", group: "Camera" }, - KeyBinding { keys: "Alt+7", action: "View bottom", group: "Camera" }, - KeyBinding { keys: "Alt+8", action: "View isometric", group: "Camera" }, - // --- Display --- - KeyBinding { keys: "Alt+Z", action: "Toggle X-ray silhouette", group: "Display" }, - // --- Edit --- - KeyBinding { keys: "Cmd+Z", action: "Undo", group: "Edit" }, - KeyBinding { keys: "Cmd+Shift+Z", action: "Redo", group: "Edit" }, - KeyBinding { keys: "Cmd+C", action: "Copy selection", group: "Edit" }, - KeyBinding { keys: "Cmd+V", action: "Paste", group: "Edit" }, - KeyBinding { keys: "Cmd+D", action: "Duplicate selection", group: "Edit" }, - KeyBinding { keys: "Cmd+A", action: "Select all", group: "Edit" }, - KeyBinding { keys: "Cmd+G", action: "Group selection", group: "Edit" }, - KeyBinding { keys: "Cmd+Shift+G", action: "Ungroup selection", group: "Edit" }, - // --- Render & UI --- - KeyBinding { keys: "F12", action: "Render high-res PNG", group: "Render & UI" }, - KeyBinding { keys: "Cmd+P", action: "Command palette", group: "Render & UI" }, - KeyBinding { keys: "F1", action: "Show this keymap help", group: "Render & UI" }, -]; - -/// Render the full grouped help text for the F1 panel, one line per row with -/// the key chord padded so the actions align. Groups render in `GROUPS` order; -/// any row whose group is unknown is appended after every named group. -pub fn render_groups() -> String { - let mut out = String::new(); - let width = BINDINGS.iter().map(|b| b.keys.len()).max().unwrap_or(0); - let mut seen: Vec<&'static str> = Vec::new(); - for &group in GROUPS { - write_group(&mut out, group, width, &mut seen); - } - // Any group not named in GROUPS (e.g. future additions) still shows. - let mut extra: Vec<&'static str> = BINDINGS - .iter() - .map(|b| b.group) - .filter(|g| !GROUPS.contains(g)) - .collect(); - extra.dedup(); - for group in extra { - write_group(&mut out, group, width, &mut seen); - } - out -} - -fn write_group(out: &mut String, group: &'static str, width: usize, seen: &mut Vec<&'static str>) { - if seen.contains(&group) { - return; - } - seen.push(group); - out.push_str(&format!("—— {} ——\n", group)); - for b in BINDINGS { - if b.group == group { - out.push_str(&format!(" {: = BINDINGS.iter().map(|b| b.keys).collect(); - keys.sort_unstable(); - for pair in keys.windows(2) { - assert_ne!(pair[0], pair[1], "duplicate key chord {:?}", pair[0]); - } - } - - /// `render_groups` must mention every binding exactly once, so the panel - /// always matches the table. Reconstruct each row with the same width - /// padding `render_groups` applies, so the match is exact (no binding can - /// accidentally match another binding's line as a substring). - #[test] - fn render_covers_every_binding_once() { - let text = render_groups(); - let width = BINDINGS.iter().map(|b| b.keys.len()).max().unwrap_or(0); - for b in BINDINGS { - let needle = format!(" {: = GROUPS.iter().map(|g| format!("—— {} ——", g)).collect(); - let mut last = 0usize; - for h in &headers { - let pos = text.find(h.as_str()).unwrap_or_else(|| { - panic!("group header {} missing from render", h) - }); - assert!(pos >= last, "group header {} out of order", h); - last = pos; - } - for h in &headers { - assert_eq!(text.matches(h.as_str()).count(), 1, "header {} duplicated", h); - } - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/measure.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/measure.rs deleted file mode 100644 index 5f0e35d..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/measure.rs +++ /dev/null @@ -1,368 +0,0 @@ -//! Measurement tool — distance, area, angle. -//! -//! Ported from fab's `tools/measure.rs`. Pure math over world-space points; -//! no rendering dependencies. The overlay drawing and status-bar hints live -//! in `viewport_render.rs` and `tools.rs` respectively. - -use super::math::{DVec3, vec3_cross, vec3_dot}; - -// ─── Types ────────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MeasureKind { - Distance, - Angle, - Area, -} - -impl MeasureKind { - pub fn needed_points(self) -> usize { - match self { - Self::Distance => 2, - Self::Angle => 3, - Self::Area => usize::MAX, // open-ended, commits on close - } - } - - pub fn label(self) -> &'static str { - match self { - Self::Distance => "Distance", - Self::Angle => "Angle", - Self::Area => "Area", - } - } -} - -#[derive(Debug, Clone)] -pub struct Measurement { - pub kind: MeasureKind, - pub points: Vec, - pub value: f64, - pub label: String, -} - -/// Real, heap-backable measurement state. Wrapped in a `RefCell` -/// newtype (`MeasureState` in `mod.rs`) so the `#[rust]` derive macro -/// accepts it as a field -- just like `PickBvhCache`. -#[derive(Clone, Debug, Default)] -pub struct MeasureInner { - /// 0 = Distance, 1 = Angle, 2 = Area. - pub kind: u32, - /// Stacked world points: point i lives at - /// `(pts_x[i], pts_y[i], pts_z[i])`. - pub pts_x: Vec, - pub pts_y: Vec, - pub pts_z: Vec, - /// Whether a measurement has been committed / is final. - pub done: bool, - /// Human-readable results ("5.00 m", "90.0°", "12.00 m²"). - pub completed: Vec, -} - -impl MeasureInner { - pub fn point(&self, i: usize) -> DVec3 { - DVec3 { - x: self.pts_x[i], - y: self.pts_y[i], - z: self.pts_z[i], - } - } - - pub fn points(&self) -> Vec { - (0..self.pts_x.len()).map(|i| self.point(i)).collect() - } - - pub fn push_point(&mut self, p: DVec3) { - self.pts_x.push(p.x); - self.pts_y.push(p.y); - self.pts_z.push(p.z); - } - - pub fn clear_points(&mut self) { - self.pts_x.clear(); - self.pts_y.clear(); - self.pts_z.clear(); - } - - pub fn len(&self) -> usize { - self.pts_x.len() - } -} - -// ─── Pure math ────────────────────────────────────────────────────────── - -/// Straight-line distance in meters. -pub fn distance(a: DVec3, b: DVec3) -> f64 { - (b - a).length() -} - -/// Area of a planar polygon via Newell's method (m²). Works for any orientation. -pub fn polygon_area(points: &[DVec3]) -> f64 { - if points.len() < 3 { - return 0.0; - } - let mut n = DVec3::default(); - for i in 0..points.len() { - let a = points[i]; - let b = points[(i + 1) % points.len()]; - n = n + vec3_cross(a, b); - } - n.length() * 0.5 -} - -/// Angle at `vertex` between rays vertex→a and vertex→b, in degrees. -pub fn angle_deg(a: DVec3, vertex: DVec3, b: DVec3) -> f64 { - let u = (a - vertex).normalize(); - let v = (b - vertex).normalize(); - vec3_dot(u, v).clamp(-1.0, 1.0).acos().to_degrees() -} - -/// How far a loop strays from its best-fit plane, in meters. -/// -/// For a non-planar loop, `polygon_area` reports the area of the projection -/// onto the best-fit plane without saying so. We measure the deviation and -/// flag it (`~` prefix) rather than quoting a number that is not the area of -/// anything. -pub fn planarity(points: &[DVec3]) -> f64 { - if points.len() < 4 { - return 0.0; - } - let mut n = DVec3::default(); - let mut c = DVec3::default(); - for i in 0..points.len() { - let a = points[i]; - let b = points[(i + 1) % points.len()]; - n = n + vec3_cross(a, b); - c = c + a; - } - let len = n.length(); - if len < 1e-9 { - return 0.0; - } - let n = n / len; - let c = c / points.len() as f64; - points - .iter() - .map(|p| vec3_dot(*p - c, n).abs()) - .fold(0.0f64, f64::max) -} - -/// Loops flatter than this count as planar (1 mm). -pub const PLANAR_TOLERANCE: f64 = 0.001; - -// ─── Formatting ───────────────────────────────────────────────────────── - -/// Format a length value in meters with the given decimal places. -pub fn format_length(meters: f64, decimals: usize) -> String { - if meters >= 1.0 { - format!("{:.prec$} m", meters, prec = decimals) - } else { - format!("{:.0} mm", meters * 1000.0) - } -} - -/// Format an area value in square meters. -pub fn format_area(sq_meters: f64, decimals: usize) -> String { - if sq_meters >= 1.0 { - format!("{:.prec$} m²", sq_meters, prec = decimals) - } else { - format!("{:.0} cm²", sq_meters * 10_000.0) - } -} - -/// Format an angle value in degrees. -pub fn format_angle(degrees: f64, decimals: usize) -> String { - format!("{:.prec$}°", degrees, prec = decimals) -} - -// ─── Commit ───────────────────────────────────────────────────────────── - -/// Compute the measurement value and format a label for a finished point set. -pub fn commit(kind: MeasureKind, points: &[DVec3], decimals: usize) -> Option { - let min = match kind { - MeasureKind::Distance => 2, - MeasureKind::Angle => 3, - MeasureKind::Area => 3, - }; - if points.len() < min { - return None; - } - let value = value_of(kind, points); - let mut label = format_value(kind, value, decimals); - if kind == MeasureKind::Area && planarity(points) > PLANAR_TOLERANCE { - label = format!("~{label}"); - } - Some(Measurement { - kind, - points: points.to_vec(), - value, - label, - }) -} - -/// Compute the raw numeric value for a set of measurement points. -pub fn value_of(kind: MeasureKind, points: &[DVec3]) -> f64 { - match kind { - MeasureKind::Distance => { - if points.len() < 2 { - 0.0 - } else { - distance(points[0], points[1]) - } - } - MeasureKind::Angle => { - if points.len() < 3 { - 0.0 - } else { - // A → corner → B: the angle is at the middle point. - angle_deg(points[0], points[1], points[2]) - } - } - MeasureKind::Area => polygon_area(points), - } -} - -/// Format a measurement value using the appropriate unit. -pub fn format_value(kind: MeasureKind, value: f64, decimals: usize) -> String { - match kind { - MeasureKind::Distance => format_length(value, decimals), - MeasureKind::Area => format_area(value, decimals), - MeasureKind::Angle => format_angle(value, decimals), - } -} - -// ─── Hints ────────────────────────────────────────────────────────────── - -/// Status-bar hint for the measure tool. -pub fn hint(kind: MeasureKind) -> &'static str { - match kind { - MeasureKind::Distance => "Click two points to measure distance · Esc Cancel", - MeasureKind::Angle => "Click A → corner → B to measure angle · Esc Cancel", - MeasureKind::Area => "Click points to outline area · Enter Close loop · Esc Cancel", - } -} - -// ─── Tests ────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - fn v(x: f64, y: f64, z: f64) -> DVec3 { - DVec3 { x, y, z } - } - - #[test] - fn distance_zero() { - let a = v(1.0, 2.0, 3.0); - assert!(distance(a, a) < 1e-12); - } - - #[test] - fn distance_unit() { - assert!((distance(v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0)) - 1.0).abs() < 1e-12); - assert!((distance(v(0.0, 0.0, 0.0), v(0.0, 3.0, 4.0)) - 5.0).abs() < 1e-12); - } - - #[test] - fn polygon_area_square() { - let square = [v(0.0, 0.0, 0.0), v(3.0, 0.0, 0.0), v(3.0, 4.0, 0.0), v(0.0, 4.0, 0.0)]; - assert!((polygon_area(&square) - 12.0).abs() < 1e-6); - } - - #[test] - fn polygon_area_triangle() { - let tri = [v(0.0, 0.0, 0.0), v(4.0, 0.0, 0.0), v(0.0, 3.0, 0.0)]; - assert!((polygon_area(&tri) - 6.0).abs() < 1e-6); - } - - #[test] - fn polygon_area_degenerate() { - assert!(polygon_area(&[v(0.0, 0.0, 0.0)]) < 1e-12); - assert!(polygon_area(&[]) < 1e-12); - } - - #[test] - fn angle_right() { - let angle = angle_deg(v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0), v(0.0, 1.0, 0.0)); - assert!((angle - 90.0).abs() < 1e-4); - } - - #[test] - fn angle_straight() { - let angle = angle_deg(v(-1.0, 0.0, 0.0), v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0)); - assert!((angle - 180.0).abs() < 1e-4); - } - - #[test] - fn angle_45() { - let angle = angle_deg(v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0), v(1.0, 1.0, 0.0)); - assert!((angle - 45.0).abs() < 1e-4); - } - - #[test] - fn planarity_flat() { - let flat = [v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0), v(1.0, 1.0, 0.0), v(0.0, 1.0, 0.0)]; - assert!(planarity(&flat) < 1e-12); - } - - #[test] - fn planarity_bent() { - let bent = [ - v(0.0, 0.0, 0.0), - v(1.0, 0.0, 0.0), - v(1.0, 0.0, 0.5), - v(0.0, 1.0, 0.0), - ]; - assert!(planarity(&bent) > 0.01); - } - - #[test] - fn commit_distance() { - let pts = vec![v(0.0, 0.0, 0.0), v(3.0, 4.0, 0.0)]; - let m = commit(MeasureKind::Distance, &pts, 2).unwrap(); - assert!((m.value - 5.0).abs() < 1e-6); - assert!(m.label.contains("5")); - } - - #[test] - fn commit_angle() { - let pts = vec![v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0), v(0.0, 1.0, 0.0)]; - let m = commit(MeasureKind::Angle, &pts, 1).unwrap(); - assert!((m.value - 90.0).abs() < 1e-4); - } - - #[test] - fn commit_area() { - let pts = vec![v(0.0, 0.0, 0.0), v(3.0, 0.0, 0.0), v(3.0, 4.0, 0.0), v(0.0, 4.0, 0.0)]; - let m = commit(MeasureKind::Area, &pts, 2).unwrap(); - assert!((m.value - 12.0).abs() < 1e-6); - } - - #[test] - fn format_length_meters() { - assert_eq!(format_length(5.5, 2), "5.50 m"); - } - - #[test] - fn format_length_millimeters() { - assert_eq!(format_length(0.012, 2), "12 mm"); - } - - #[test] - fn format_area_value() { - assert_eq!(format_area(12.5, 1), "12.5 m²"); - } - - #[test] - fn format_angle_value() { - assert_eq!(format_angle(90.0, 1), "90.0°"); - } - - #[test] - fn needed_points() { - assert_eq!(MeasureKind::Distance.needed_points(), 2); - assert_eq!(MeasureKind::Angle.needed_points(), 3); - assert_eq!(MeasureKind::Area.needed_points(), usize::MAX); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/mod.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/mod.rs index 2cb3433..a6b8cac 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/mod.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/mod.rs @@ -25,6 +25,7 @@ use makepad_code_editor::{ CodeDocument, CodeEditor, CodeSession, }; use makepad_draw::DrawVector; +use makepad_widgets::adaptive_view::AdaptiveView; use makepad_widgets::makepad_platform::event::TouchState; use makepad_widgets::makepad_platform::{makepad_script::ScriptVmBase, thread::SignalToUI}; use makepad_widgets::*; @@ -50,21 +51,6 @@ pub mod arch_svg; // the grouping is tested even though the GPU submission cannot be. // Phase 3 of the render plan. pub mod batching; -pub mod bvh; -pub mod camera_orbit; -pub mod command_palette; -pub mod dashboard; -pub mod drag_num; -pub mod explode; -pub mod keymap; -pub mod measure; -pub mod outliner; -pub mod properties; -pub mod render_export; -pub mod script_parts; -pub mod section; -pub mod sun; -pub mod snap; // pub mod cost_estimator; pub mod cad_editor_sheet; // cad_scene: immutable scene graph + Exporter trait + SceneVisitor + MeshCache. @@ -181,9 +167,6 @@ pub struct DrawCadMesh { light_dir: Vec3f, #[rust(vec3(0.62, 0.42, -0.58))] fill_dir: Vec3f, - /// X-ray silhouette toggle (flat blue tint across the whole mesh). - #[rust(0.0f32)] - xray: f32, /// Open instanced batch, if one is running. Phase 3 of the render /// plan. /// @@ -406,7 +389,6 @@ script_mod! { v_world: varying(vec3f) v_normal: varying(vec3f) display_mode: 4.0 - xray: uniform(float, 0.0) active_camera_world_pos: fn() -> vec3f { let camera_world = self.draw_pass.camera_inv * vec4(0.0, 0.0, 0.0, 1.0) @@ -451,14 +433,6 @@ script_mod! { let fill = abs(dot(normal, normalize(self.u_fill_dir))) let rim = pow(max(1.0 - abs(dot(normal, view_dir)), 0.0), 2.5) - if self.xray > 0.5 { - // X-ray silhouette: a flat translucent-blue tint across the - // whole mesh so interior geometry reads through as a blue - // technical overlay. The batch stays opaque (alpha_blend is - // off) so this is a colour mode, not a depth hack. - return vec4(vec3(0.22, 0.50, 0.95), 1.0) - } - if self.display_mode < 0.5 { // Wireframe: filled surfaces are skipped in Rust draw_scene(); // this fallback stays very dark if a mesh accidentally reaches here. @@ -543,24 +517,17 @@ script_mod! { mod.widgets.CadWorkspaceBase = #(CadWorkspace::register_widget(vm)) mod.widgets.CadWorkspace = set_type_default() do mod.widgets.CadWorkspaceBase{ width: Fill, height: Fill - flow: Overlay - // =============== Editor variants (Desktop/Mobile) =============== - // The responsive Desktop/Mobile layouts live inside a nested - // AdaptiveView so the whole set can be covered by (or replaced by) - // the project dashboard overlay drawn on top when `show_dashboard`. - editor_variant := mod.widgets.AdaptiveView { + // =============== Desktop variant (wide screens) =============== + // Layout: header at top, then an Overlay area where: + // - cad_viewport fills the entire area + // - viewport_toolbar floats over the top-left of the viewport + // - bottom_overlay floats over the bottom: script editor on the left, + // AI prompt panel on the right + // Toggle the bottom_overlay via toggle_editor_btn in the header. + Desktop := View { width: Fill, height: Fill - // =============== Desktop variant (wide screens) =============== - // Layout: header at top, then an Overlay area where: - // - cad_viewport fills the entire area - // - viewport_toolbar floats over the top-left of the viewport - // - bottom_overlay floats over the bottom: script editor on the left, - // AI prompt panel on the right - // Toggle the bottom_overlay via toggle_editor_btn in the header. - Desktop := View { - width: Fill, height: Fill - flow: Down + flow: Down workspace_header := SolidView { width: Fill; height: Fit @@ -589,10 +556,6 @@ script_mod! { draw_bg +: { color: #x2a5c3a; color_hover: #x3a7a4a; color_down: #x4a8a5a; border_radius: 6.0 } draw_text +: { color: #xe6edf3; text_style +: {font_size: 10.0} } } - back_to_dash_btn := Button { width: 64; height: 24; text: "Projects" - draw_bg +: { color: #x374151; color_hover: #x4b5563; color_down: #x6b7280; border_radius: 6.0 } - draw_text +: { color: #xe6edf3; text_style +: {font_size: 9.5} } - } workspace_split_toggle_btn := Button { width: 92; height: 24; text: "Split 2D/3D" draw_bg +: { color: #x2a333c; color_hover: #x3f4b56; color_down: #x4a5b66; border_radius: 6.0 } draw_text +: { color: #xe6edf3; text_style +: {font_size: 9.0} } @@ -1323,10 +1286,6 @@ script_mod! { draw_bg +: { color: #x2a5c3a; color_hover: #x3a7a4a; color_down: #x4a8a5a; border_radius: 5.0 } draw_text +: { color: #xe6edf3; text_style +: {font_size: 9.0} } } - back_to_dash_btn := Button { width: 56; height: 22; text: "Project" - draw_bg +: { color: #x374151; color_hover: #x4b5563; color_down: #x6b7280; border_radius: 5.0 } - draw_text +: { color: #xe6edf3; text_style +: {font_size: 8.5} } - } workspace_split_toggle_btn := Button { width: 66; height: 22; text: "Split" draw_bg +: { color: #x2a333c; color_hover: #x3f4b56; color_down: #x4a5b66; border_radius: 5.0 } draw_text +: { color: #xe6edf3; text_style +: {font_size: 8.5} } @@ -1523,8 +1482,6 @@ script_mod! { zoom_in_button := Button{ width: 28.0 text: "+" } zoom_out_button := Button{ width: 28.0 text: "-" } fit_button := Button{ width: 30.0 text: "Fit" } - outliner_toggle_btn := Button{ width: 34.0 text: "List" draw_text +: { text_style +: { font_size: 8.0 } } } - palette_toggle_btn := Button{ width: 34.0 text: "Cmd" draw_text +: { text_style +: { font_size: 8.0 } } } } row3 := View { @@ -1553,191 +1510,6 @@ script_mod! { } } - // === Outliner panel: floats over the viewport, toggled from row2 === - outliner_panel := View { - width: Fill - height: Fill - flow: Overlay - visible: false - show_bg: true - new_batch: true - draw_bg +: { color: #x0d1218 } - - View { - width: Fill - height: Fill - flow: Down - align: Align{x: 0.0 y: 0.0} - - outliner_header := View { - width: Fill; height: 26.0 - flow: Right; spacing: 4.0 - padding: Inset{left: 8.0 top: 4.0 right: 8.0 bottom: 4.0} - show_bg: true - draw_bg +: { color: #x171d24 } - - Label { width: Fill; height: Fit; text: "Outliner" draw_text +: { color: #x9aa8b5 text_style +: { font_size: 10.0 } } } - outliner_sel_prev_btn := Button{ width: 30.0 text: "▲" draw_text +: { text_style +: { font_size: 8.0 } } } - outliner_sel_next_btn := Button{ width: 30.0 text: "▼" draw_text +: { text_style +: { font_size: 8.0 } } } - outliner_toggle_vis_btn := Button{ width: 46.0 text: "Hide" draw_text +: { text_style +: { font_size: 8.0 } } } - outliner_close_btn := Button{ width: 30.0 text: "✕" draw_text +: { text_style +: { font_size: 8.0 } } } - } - - outliner_search_row := View { - width: Fill; height: 26.0 - flow: Right; spacing: 4.0 - padding: Inset{left: 8.0 top: 2.0 right: 8.0 bottom: 2.0} - show_bg: true - draw_bg +: { color: #x141a21 } - outliner_search_input := TextInput { - width: Fill; height: Fill - text: "" - empty_message: "Search name/kind…" - draw_text +: { color: #xd8dee6 text_style +: { font_size: 10.0 } } - } - outliner_count_label := Label { - width: Fit; height: Fit - text: "0/0" - draw_text +: { color: #x9aa8b5 text_style +: { font_size: 9.0 } } - } - outliner_kind_btn := Button{ width: 44.0 text: "Kind" draw_text +: { text_style +: { font_size: 8.0 } } } - } - - View { - width: Fill - height: 4.0 - } - - outliner_text_view := View { - width: Fill - height: Fill - outliner_text_label := Label { - width: Fill - height: Fit - text: "" - draw_text +: { color: #xd8dee6 text_style +: { font_size: 10.0 } } - } - } - - View { - width: Fill - height: 4.0 - } - - outliner_actions := View { - width: Fill; height: Fit - flow: Right; spacing: 4.0 - padding: Inset{left: 8.0 top: 0.0 right: 8.0 bottom: 6.0} - outliner_hide_all_btn := Button{ width: 72.0 text: "Hide all" draw_text +: { text_style +: { font_size: 8.0 } } } - outliner_show_all_btn := Button{ width: 78.0 text: "Show all" draw_text +: { text_style +: { font_size: 8.0 } } } - outliner_isolate_btn := Button{ width: 66.0 text: "Isolate" draw_text +: { text_style +: { font_size: 8.0 } } } - outliner_info_btn := Button{ width: 48.0 text: "Info" draw_text +: { text_style +: { font_size: 8.0 } } } - } - section_controls := View { - width: Fill; height: Fit - flow: Right; spacing: 4.0 - padding: Inset{left: 8.0 top: 0.0 right: 8.0 bottom: 6.0} - section_x_btn := Button{ width: 44.0 text: "Sec X" draw_text +: { text_style +: { font_size: 8.0 } } } - section_y_btn := Button{ width: 44.0 text: "Sec Y" draw_text +: { text_style +: { font_size: 8.0 } } } - section_z_btn := Button{ width: 44.0 text: "Sec Z" draw_text +: { text_style +: { font_size: 8.0 } } } - section_clear_btn := Button{ width: 60.0 text: "Clear" draw_text +: { text_style +: { font_size: 8.0 } } } - explode_minus_btn := Button{ width: 42.0 text: "Ex-" draw_text +: { text_style +: { font_size: 8.0 } } } - explode_plus_btn := Button{ width: 42.0 text: "Ex+" draw_text +: { text_style +: { font_size: 8.0 } } } - sun_toggle_btn := Button{ width: 52.0 text: "Sun" draw_text +: { text_style +: { font_size: 8.0 } } } - sun_hour_down_btn := Button{ width: 30.0 text: "-h" draw_text +: { text_style +: { font_size: 8.0 } } } - sun_hour_up_btn := Button{ width: 30.0 text: "+h" draw_text +: { text_style +: { font_size: 8.0 } } } - xray_btn := Button{ width: 50.0 text: "X-Ray" draw_text +: { text_style +: { font_size: 8.0 } } } - } - } - } - - // === Command palette: floats over the viewport, fuzzy search over commands === - palette_panel := View { - width: Fill - height: Fill - flow: Overlay - visible: false - show_bg: true - new_batch: true - draw_bg +: { color: #x0d1218 } - - View { - width: Fill - height: Fit - flow: Down - spacing: 4.0 - padding: Inset{left: 8.0 top: 8.0 right: 8.0 bottom: 8.0} - align: Align{x: 0.0 y: 0.0} - show_bg: true - draw_bg +: { color: #x141b22 } - - palette_input := TextInput { - width: Fill; height: 26.0 - empty_text: "Search commands…" - draw_bg +: { color: #x0d1218 border_radius: 4.0 } - draw_text +: { color: #xd8dee6 text_style +: { font_size: 10.0 } } - } - - palette_text_view := View { - width: Fill - height: Fill - palette_text_label := Label { - width: Fill - height: Fit - text: "" - draw_text +: { color: #xd8dee6 text_style +: { font_size: 10.0 } } - } - } - - palette_actions := View { - width: Fill; height: Fit - flow: Right; spacing: 4.0 - palette_prev_btn := Button{ width: 36.0 text: "▲" draw_text +: { text_style +: { font_size: 8.0 } } } - palette_next_btn := Button{ width: 36.0 text: "▼" draw_text +: { text_style +: { font_size: 8.0 } } } - palette_run_btn := Button{ width: 72.0 text: "Run" draw_text +: { text_style +: { font_size: 8.0 } } } - palette_close_btn := Button{ width: 36.0 text: "✕" draw_text +: { text_style +: { font_size: 8.0 } } } - } - } - } - - // === F1 keymap help: floats over the viewport, renders the keymap table === - keymap_panel := View { - width: Fill - height: Fill - flow: Overlay - visible: false - show_bg: true - new_batch: true - draw_bg +: { color: #x0d1218 } - - View { - width: Fill - height: Fill - flow: Down - spacing: 4.0 - padding: Inset{left: 8.0 top: 8.0 right: 8.0 bottom: 8.0} - - View { - width: Fill - height: Fit - flow: Right - align: Align{x: 1.0 y: 0.0} - keymap_close_btn := Button{ width: 36.0 text: "✕" draw_text +: { text_style +: { font_size: 8.0 } } } - } - - keymap_text_view := View { - width: Fill - height: Fill - keymap_text_label := Label { - width: Fill - height: Fit - text: "" - draw_text +: { color: #xd8dee6 text_style +: { font_size: 10.0 } } - } - } - } - } - // === Bottom overlay — stacked: AI prompt row on top, script editor below === bottom_overlay_slot := View { width: Fill @@ -1852,10 +1624,6 @@ script_mod! { } } } - } - // Project dashboard: shown as a full-size overlay when `show_dashboard` - // is set on CadWorkspace; sits above the Desktop/Mobile editor variants. - dashboard := mod.widgets.CadDashboard {} } // =========================================================================== @@ -1868,27 +1636,6 @@ script_mod! { // [moved to viewport.rs: struct CadViewportViewSnapshot] -/// Newtype wrapper so the `#[rust]` derive macro accepts the BVH cache -/// field. Complex `RefCell>` types cause "Unexpected field form". -struct PickBvhCache(std::cell::RefCell>); - -impl Default for PickBvhCache { - fn default() -> Self { - Self(std::cell::RefCell::new(None)) - } -} - -/// Newtype wrapper for the Measure tool state, mirroring `PickBvhCache` -/// so the `#[rust]` derive macro accepts the field. The real heap state -/// lives in `measure::MeasureInner`. -struct MeasureState(std::cell::RefCell); - -impl Default for MeasureState { - fn default() -> Self { - Self(std::cell::RefCell::new(measure::MeasureInner::default())) - } -} - #[derive(Script, ScriptHook, WidgetRef, WidgetSet, WidgetRegister)] pub struct CadViewport { #[uid] @@ -1919,10 +1666,6 @@ pub struct CadViewport { ground_color: Vec4f, #[live] camera: XrCamera, - #[rust(false)] - ortho_enabled: bool, - #[rust(10.0f32)] - ortho_height: f32, #[new] pass: DrawPass, #[new] @@ -1995,13 +1738,6 @@ pub struct CadViewport { /// `mark_dirty()` + `invalidate_node(id)`. #[rust] scene_cache: SceneCache, - /// BVH acceleration structure for O(log n) ray picking. - /// - /// Built lazily on the first pick after a scene change and cached - /// until the next `mark_dirty()`. The tuple is `(generation, bvh)` - /// where generation comes from the parts store to detect staleness. - #[rust] - pick_bvh: PickBvhCache, /// Shared with the other two viewports (see /// `CadWorkspace::share_part_id_allocator`). Not a plain `u64`: /// three independent counters synced by copy reissued live ids. @@ -2009,30 +1745,10 @@ pub struct CadViewport { part_ids: PartIdAllocator, #[rust] selection: Vec, - #[rust(false)] - selection_dirty: bool, #[rust(ViewMode::ThreeD)] view_mode: ViewMode, #[rust(CadRenderMode::Realistic)] render_mode: CadRenderMode, - // ---- Section plane (CPU clip) ---- - #[rust(false)] - section_active: bool, - #[rust(0u8)] - section_axis: u8, - #[rust(0.0f64)] - section_offset: f64, - // ---- Explode view ---- - #[rust(0.0f64)] - explode_amount: f64, - // ---- Sun study ---- - #[rust(false)] - sun_active: bool, - #[rust(12.0f64)] - sun_hour: f64, - // ---- X-ray silhouette ---- - #[rust(false)] - xray: bool, #[rust(2.6f32)] ortho_zoom: f32, #[rust] @@ -2131,9 +1847,6 @@ pub struct CadViewport { tool: CadTool, #[rust] drawing: DrawingState, - /// In-progress / completed measurement state for the Measure tool. - #[rust] - measure: MeasureState, #[rust] snap: SnapSettings, // ---- Profile: frame timing diagnostics ---- @@ -2304,7 +2017,7 @@ enum CadViewportLayoutMode { #[derive(Script, ScriptHook, Widget)] pub struct CadWorkspace { #[deref] - view: View, + view: AdaptiveView, #[rust(false)] initialized: bool, #[rust] @@ -2350,17 +2063,6 @@ pub struct CadWorkspace { #[rust(true)] editors_visible: bool, - /// True while the project dashboard (file grid) is shown instead of - /// the editor. Lands on the dashboard first; New/Open hides it. - #[rust(true)] - show_dashboard: bool, - - /// Previous `show_dashboard` value, so the visibility toggle can tell - /// a transition apart from a steady state and only refresh the - /// dashboard file list (and redraw) once when it appears. - #[rust(false)] - dashboard_prev_visible: bool, - /// True once the bottom sheet's screen rect has been pushed into viewports /// at least once. Before this, `update_sheet_rect_for_viewports` is called /// on every event so the viewport's `blocked_by_sheet` guard works from the @@ -2409,38 +2111,6 @@ pub struct CadWorkspace { attached_image_base64: Option, #[rust(None)] attached_image_filename: Option, - - /// Whether the outliner panel overlay is currently visible. - #[rust(false)] - outliner_open: bool, - - /// Whether the command palette overlay is currently visible. - #[rust(false)] - palette_open: bool, - - /// Whether the F1 keymap help overlay is currently visible. - #[rust(false)] - keymap_open: bool, - - /// Current palette filter query text. - #[rust] - palette_query: String, - - /// Ranked results (subset of COMMANDS) for the current query. - #[rust] - palette_hits: Vec, - - /// Highlighted row index into `palette_hits`. - #[rust(0)] - palette_cursor: usize, - - /// Live outliner search query (matched against name/kind). - #[rust] - outliner_filter_query: String, - - /// Optional outliner funnel: only show parts of this kind. - #[rust(None)] - outliner_kind_filter: Option, } // [extracted to impl CadWorkspace] @@ -2451,7 +2121,6 @@ pub fn register_cad(vm: &mut ScriptVm) { cad_script_mod(vm); cost_estimator::script_mod(vm); cad_editor_sheet::script_mod(vm); - dashboard::script_mod(vm); script_mod(vm); } diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/outliner.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/outliner.rs deleted file mode 100644 index c4590b8..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/outliner.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! Outliner: a compact scene-outline readout. -//! -//! Pure logic that turns the part list into a numbered outline so the -//! workspace/outliner widget can render a self-contained "scene tree": -//! one line per part with its name, kind, visibility marker and -//! selection marker. Separated from the DSL so the formatting is -//! unit-tested without a display. -//! -//! Markers: `●` visible, `○` hidden, `►` selected (suffix). The leading -//! integer is the stable per-part key the user can use to select/toggle -//! that part. - -use super::cad_scene::{CadNode, PartKind}; - -const VIS: &str = "●"; -const HID: &str = "○"; - -/// Build the multi-line outliner text for a list of parts. -/// -/// Each part becomes a line, e.g. ` 0 ● Wall 1 (Wall)` or -/// ` 1 ○ Cube 2 (Cube) ►`. -pub fn outliner_text(parts: &[&CadNode], selected: &[u64]) -> String { - if parts.is_empty() { - return "No parts".to_string(); - } - let mut out = String::new(); - for (i, p) in parts.iter().enumerate() { - let marker = if p.is_hidden() { HID } else { VIS }; - let name = p.name.trim(); - let kind = p.part_kind().label(); - let arrow = if selected.contains(&p.id.raw()) { " ►" } else { "" }; - let label = if name.is_empty() { - format!("({kind})") - } else { - format!("{name} ({kind})") - }; - out.push_str(&format!("{:>4} {marker} {label}{arrow}\n", i)); - } - out -} - -/// One-line hint shown when the scene is empty. -pub fn empty_hint() -> &'static str { - "Scene is empty" -} - -/// Format owned outliner rows (from `CadViewport::outliner_rows`). -/// Row = `(id, name, kind, hidden, selected)`. -pub fn outliner_text_rows(rows: &[(u64, String, PartKind, bool, bool)]) -> String { - if rows.is_empty() { - return "No parts".to_string(); - } - let mut out = String::new(); - for (i, (_, name, kind, hidden, selected)) in rows.iter().enumerate() { - let marker = if *hidden { HID } else { VIS }; - let kind_label = kind.label(); - let trim = name.trim(); - let label = if trim.is_empty() { - format!("({kind_label})") - } else { - format!("{trim} ({kind_label})") - }; - let arrow = if *selected { " ►" } else { "" }; - out.push_str(&format!("{:>4} {marker} {label}{arrow}\n", i)); - } - out -} - -/// A single outliner row: `(id, name, kind, hidden, selected)`. -pub type Row = (u64, String, PartKind, bool, bool); - -/// Filter outliner rows by a substring query against the name **or** the kind -/// label (case-insensitive). An empty query keeps every row. Purely functional. -pub fn filter_rows(rows: &[Row], query: &str) -> Vec { - let q = query.trim().to_lowercase(); - if q.is_empty() { - return rows.to_vec(); - } - rows.iter() - .filter(|(_, name, kind, _, _)| { - name.to_lowercase().contains(&q) || kind.label().to_lowercase().contains(&q) - }) - .cloned() - .collect() -} - -/// Further filter rows to a single part kind if `Some`. Keeps order. -pub fn filter_rows_by_kind(rows: &[Row], kind: Option) -> Vec { - match kind { - None => rows.to_vec(), - Some(k) => rows - .iter() - .filter(|(_, _, rk, _, _)| *rk == k) - .cloned() - .collect(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use super::super::cad_scene::CadSolid; - - fn node(name: &str, kind: PartKind, hidden: bool, id: u64) -> CadNode { - let solid = CadSolid::Box { - size: makepad_widgets::Vec3f { x: 1.0, y: 1.0, z: 1.0 }, - }; - let mut n = CadNode { - id: super::super::cad_scene::NodeId(id), - name: name.into(), - solid: Some(solid), - transform: super::super::cad_scene::CadTransform::IDENTITY, - material: super::super::cad_scene::MaterialId::ROOT, - layer: super::super::cad_scene::LayerId::ROOT, - parent: None, - metadata: super::super::cad_scene::NodeMetadata::default(), - color: makepad_widgets::Vec4f { x: 1.0, y: 1.0, z: 1.0, w: 1.0 }, - kind_hint: Some(kind), - }; - n.set_hidden(hidden); - n - } - - #[test] - fn empty_scene_shows_hint() { - assert!(outliner_text(&[], &[]).starts_with("No parts")); - } - - #[test] - fn lists_each_part_with_visibility_and_kind() { - let a = node("Wall 1", PartKind::Wall, false, 1); - let b = node("Cube 2", PartKind::Cube, true, 2); - let txt = outliner_text(&[&a, &b], &[]); - assert!(txt.contains("Wall 1 (Wall)")); - assert!(txt.contains("Cube 2 (Cube)")); - let lines: Vec<&str> = txt.lines().collect(); - assert_eq!(lines.len(), 2); - assert!(lines[0].contains(VIS)); - assert!(lines[1].contains(HID)); - assert!(lines[0].contains('0')); - assert!(lines[1].contains('1')); - } - - #[test] - fn marks_selected() { - let a = node("A", PartKind::Beam, false, 3); - let b = node("B", PartKind::Slab, false, 4); - let txt = outliner_text(&[&a, &b], &[a.id.raw()]); - let lines: Vec<&str> = txt.lines().collect(); - assert!(lines[0].contains("►")); - assert!(!lines[1].contains("►")); - } - - #[test] - fn unnamed_part_falls_back_to_kind() { - let a = node("", PartKind::Column, false, 5); - let txt = outliner_text(&[&a], &[]); - assert!(txt.contains("(Column)")); - } - - #[test] - fn owned_rows_format_with_markers() { - let rows = vec![ - (1, "Wall 1".to_string(), PartKind::Wall, false, true), - (2, String::new(), PartKind::Cube, true, false), - ]; - let txt = outliner_text_rows(&rows); - let lines: Vec<&str> = txt.lines().collect(); - assert_eq!(lines.len(), 2); - assert!(lines[0].contains("Wall 1 (Wall)")); - assert!(lines[0].contains(VIS)); - assert!(lines[0].contains("►")); - assert!(lines[1].contains(HID)); - assert!(lines[1].contains("(Cube)")); - } - - #[test] - fn filter_rows_by_name() { - let rows = vec![ - (1, "Wall A".to_string(), PartKind::Wall, false, false), - (2, "Cube B".to_string(), PartKind::Cube, false, false), - ]; - assert_eq!(filter_rows(&rows, "wall").len(), 1); - assert_eq!(filter_rows(&rows, "wall")[0].0, 1); - assert_eq!(filter_rows(&rows, "b").len(), 1); - assert_eq!(filter_rows(&rows, "b")[0].0, 2); - } - - #[test] - fn filter_rows_by_kind_label() { - let rows = vec![ - (1, "A".to_string(), PartKind::Wall, false, false), - (2, "B".to_string(), PartKind::Slab, false, false), - (3, "C".to_string(), PartKind::Wall, false, false), - ]; - // Query "slab" matches the kind label even though no name has it. - assert_eq!(filter_rows(&rows, "slab").len(), 1); - assert_eq!(filter_rows(&rows, "slab")[0].0, 2); - } - - #[test] - fn filter_rows_empty_query_keeps_all() { - let rows = vec![ - (1, "Wall A".to_string(), PartKind::Wall, false, false), - (2, "Cube B".to_string(), PartKind::Cube, false, false), - ]; - assert_eq!(filter_rows(&rows, "").len(), 2); - assert_eq!(filter_rows(&rows, " ").len(), 2); - } - - #[test] - fn filter_rows_case_insensitive_and_no_match() { - let rows = vec![ - (1, "Wall A".to_string(), PartKind::Wall, false, false), - ]; - assert_eq!(filter_rows(&rows, "WALL").len(), 1); - assert!(filter_rows(&rows, "zzz").is_empty()); - } - - #[test] - fn filter_rows_by_kind_selects_one_kind() { - let rows = vec![ - (1, "A".to_string(), PartKind::Wall, false, false), - (2, "B".to_string(), PartKind::Slab, false, false), - (3, "C".to_string(), PartKind::Wall, false, false), - ]; - let walls = filter_rows_by_kind(&rows, Some(PartKind::Wall)); - assert_eq!(walls.len(), 2); - assert!(walls.iter().all(|(_, _, k, _, _)| *k == PartKind::Wall)); - assert_eq!(filter_rows_by_kind(&rows, None).len(), 3); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/properties.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/properties.rs deleted file mode 100644 index 3658603..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/properties.rs +++ /dev/null @@ -1,217 +0,0 @@ -//! Selection properties readout. -//! -//! Pure logic that turns selected parts (`CadNode`) into a compact, -//! human-readable properties string shown in the status bar. Separated -//! from the DSL so the formatting and unit logic are unit-tested without -//! a display. - -use super::cad_scene::{CadNode, CadSolid, PartKind}; -use makepad_widgets::Vec3f; - -/// Multi-line properties text for the current selection. -/// -/// - With no selection: an empty string (the caller shows a hint instead). -/// - With one part: name, kind, position and size (when the solid has a -/// closed-form size). -/// - With many parts: the count and the distinct kinds. -pub fn selection_properties(parts: &[&CadNode]) -> String { - if parts.is_empty() { - return String::new(); - } - if parts.len() == 1 { - single_part(parts[0]) - } else { - let mut kinds = std::collections::BTreeSet::new(); - for p in parts { - kinds.insert(p.part_kind().label()); - } - let joined = kinds.into_iter().collect::>().join(", "); - format!("{} parts · {}", parts.len(), joined) - } -} - -fn single_part(p: &CadNode) -> String { - let kind_label = p.part_kind().label(); - let name = p.name.trim(); - let size = p.size(); - let pos = p.pos(); - // 2D and mesh-derived solids have usable extents; skip the size block - // for the handful with no closed form (Polygon2D/ExtrudedPolygon/Arc). - let size_str = match p.solid.as_ref() { - Some(CadSolid::Box { .. }) - | Some(CadSolid::Cylinder { .. }) - | Some(CadSolid::Sphere { .. }) - | Some(CadSolid::Rect2D { .. }) - | Some(CadSolid::Circle2D { .. }) => format_size(size), - _ => String::new(), - }; - - let pos_str = fmt_vec3(pos); - if name.is_empty() { - format!("{kind_label} · pos {pos_str} · {size_str}") - } else { - format!("{name} ({kind_label}) · pos {pos_str} · {size_str}") - } -} - -/// Best-effort formatted size: "1.5 × 3.0 × 2.0 m". -fn format_size(size: Vec3f) -> String { - format!("{} × {} × {} m", trim(size.x), trim(size.y), trim(size.z)) -} - -/// Trim a length to at most two decimals, dropping useless trailing zeros. -fn trim(v: f32) -> String { - let mut s = format!("{:.2}", v); - while s.ends_with('0') { - s.pop(); - } - if s.ends_with('.') { - s.pop(); - } - s -} - -/// "(1.2, 3.4, 5.6)" from a position vector. -fn fmt_vec3(v: Vec3f) -> String { - format!("({}, {}, {})", trim(v.x), trim(v.y), trim(v.z)) -} - -/// Hint shown when nothing is selected and the properties readout is empty. -pub fn no_selection_hint() -> &'static str { - "Select a part to see its properties" -} - -/// Multi-line element info card (the "I" readout): kind, name, id, position, -/// size and triangle count. Looser and more inspectable than the status-bar -/// `selection_properties`; used by the info-card overlay and outliner reveal. -pub fn info_card_text(p: &CadNode, tri_count: usize) -> String { - let mut out = String::new(); - let kind_label = p.part_kind().label(); - let name = p.name.trim(); - if name.is_empty() { - out.push_str(&format!("{kind_label}\n")); - } else { - out.push_str(&format!("{name} ({kind_label})\n")); - } - out.push_str(&format!("ID {}\n", p.id.raw())); - out.push_str(&format!("Pos {}\n", fmt_vec3(p.pos()))); - let size_str = match p.solid.as_ref() { - Some(CadSolid::Box { .. }) - | Some(CadSolid::Cylinder { .. }) - | Some(CadSolid::Sphere { .. }) - | Some(CadSolid::Rect2D { .. }) - | Some(CadSolid::Circle2D { .. }) => format_size(p.size()), - _ => String::new(), - }; - if !size_str.is_empty() { - out.push_str(&format!("Size {size_str}\n")); - } - out.push_str(&format!("Tris {tri_count}")); - out -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::makepad_csg::Vec3d; - - fn node(name: &str, kind: PartKind) -> CadNode { - let solid = match kind { - PartKind::Wall => CadSolid::Box { - size: Vec3f { x: 4.0, y: 0.15, z: 2.4 }, - }, - _ => CadSolid::Box { - size: Vec3f { x: 1.0, y: 2.0, z: 3.0 }, - }, - }; - CadNode { - id: crate::construction_frame::pages::workspace::cad::cad_scene::NodeId(0), - name: name.into(), - solid: Some(solid), - transform: crate::construction_frame::pages::workspace::cad::cad_scene::CadTransform::IDENTITY, - material: crate::construction_frame::pages::workspace::cad::cad_scene::MaterialId::ROOT, - layer: crate::construction_frame::pages::workspace::cad::cad_scene::LayerId::ROOT, - parent: None, - metadata: crate::construction_frame::pages::workspace::cad::cad_scene::NodeMetadata::default(), - color: makepad_widgets::Vec4f { x: 1.0, y: 1.0, z: 1.0, w: 1.0 }, - kind_hint: Some(kind), - } - } - - #[test] - fn no_selection_is_empty() { - assert_eq!(selection_properties(&[]), ""); - } - - #[test] - fn single_part_shows_name_kind_size() { - let p = node("Wall 1", PartKind::Wall); - let txt = selection_properties(&[&p]); - assert!(txt.contains("Wall 1")); - assert!(txt.contains("Wall")); - // Box size 4.0 x 0.15 x 2.4 -> "4 × 0.15 × 2.4 m" - assert!(txt.contains("2.4 m")); - } - - #[test] - fn single_part_without_name_shows_kind_only() { - let p = node("", PartKind::Cube); - let txt = selection_properties(&[&p]); - assert!(txt.contains("Cube")); - assert!(!txt.contains("()")); - } - - #[test] - fn multiple_parts_show_count_and_kinds() { - let a = node("a", PartKind::Cube); - let b = node("b", PartKind::Wall); - let c = node("c", PartKind::Cube); - let txt = selection_properties(&[&a, &b, &c]); - assert!(txt.starts_with("3 parts")); - assert!(txt.contains("Cube")); - assert!(txt.contains("Wall")); - } - - #[test] - fn dedicated_formatting() { - assert_eq!(trim(2.0), "2"); - assert_eq!(trim(2.40), "2.4"); - assert_eq!(format_size(Vec3f { x: 1.0, y: 2.5, z: 3.0 }), "1 × 2.5 × 3 m"); - } - - #[test] - fn part_kind_labels() { - let _ = Vec3d::default(); - assert_eq!(PartKind::Wall.label(), "Wall"); - assert_eq!(PartKind::Cylinder.label(), "Cylinder"); - assert_eq!(PartKind::Beam.label(), "Beam"); - } - - #[test] - fn info_card_shows_kind_id_pos_size_and_tris() { - let p = node("Wall 1", PartKind::Wall); - let txt = info_card_text(&p, 42); - assert!(txt.contains("Wall 1")); - assert!(txt.contains("Wall")); - assert!(txt.contains("ID 0")); - assert!(txt.contains("Pos")); - assert!(txt.contains("2.4 m")); - assert!(txt.contains("Tris 42")); - } - - #[test] - fn info_card_no_size_for_arc() { - let mut p = node("p", PartKind::Arc); - p.solid = Some(crate::construction_frame::pages::workspace::cad::cad_scene::CadSolid::Arc { - center_x: 0.0, - center_z: 0.0, - radius: 2.0, - start_angle: 0.0, - end_angle: 90.0, - sweep_direction: 1.0, - }); - let txt = info_card_text(&p, 1); - assert!(!txt.contains("Size")); - assert!(txt.contains("Tris 1")); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/render_export.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/render_export.rs deleted file mode 100644 index f151050..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/render_export.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! High-res render capture settings + PNG export. -//! -//! There is no GPU read-back in this make/build, so a "render" is expressed -//! as pure settings (width/height/samples) plus a PNG encoder that reuses the -//! already-tested `image` encoder surfaced by `nigig_core` — no new dependency. - -/// Sampling / output settings for a high-res render capture. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct RenderSettings { - /// Output width in pixels. - pub width: u32, - /// Output height in pixels. - pub height: u32, - /// Samples per pixel (accumulation passes). `0` = single pass. - pub samples: u32, -} - -impl Default for RenderSettings { - fn default() -> Self { - RenderSettings { - width: 1600, - height: 2000, - samples: 1, - } - } -} - -impl RenderSettings { - /// Clamp the settings to sane render bounds, raising `samples` to at least - /// 1 so callers never request zero accumulation. - pub fn sanitize(mut self) -> Self { - self.width = self.width.clamp(64, 8192); - self.height = self.height.clamp(64, 8192); - self.samples = self.samples.max(1); - self - } - - /// Total number of pixels the output buffer holds. - pub fn pixel_count(&self) -> u64 { - self.width as u64 * self.height as u64 - } -} - -/// Encode a raw RGB framebuffer (3 bytes per pixel, row-major) into PNG bytes -/// at the settings' resolution. Reuses nigig-core's `image`-based encoder. -pub fn encode_render_png( - settings: &RenderSettings, - rgb: &[u8], -) -> std::io::Result> { - let want = (settings.pixel_count() * 3) as usize; - if rgb.len() != want { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!( - "render buffer size {buf} != expected {want} for {w}x{h}", - buf = rgb.len(), - w = settings.width, - h = settings.height - ), - )); - } - nigig_core::syncing::encode_png_rgb(settings.width as usize, settings.height as usize, rgb) -} - -/// Write a render to `png` next to `output_path` (replacing any extension with -/// `.png`) and return the written path. -pub fn write_render_png( - settings: &RenderSettings, - rgb: &[u8], - output_path: &str, -) -> std::io::Result { - let bytes = encode_render_png(settings, rgb)?; - let png_path = std::path::Path::new(output_path) - .with_extension("png"); - if let Some(parent) = png_path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent)?; - } - } - std::fs::write(&png_path, bytes)?; - Ok(png_path.display().to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_settings_are_render_like() { - let s = RenderSettings::default(); - assert!(s.width >= 1280); - assert!(s.height >= 1280); - assert_eq!(s.samples, 1); - } - - #[test] - fn sanitize_clamps_and_forces_at_least_one_sample() { - let s = RenderSettings { - width: 1, - height: 999999, - samples: 0, - } - .sanitize(); - assert_eq!(s.width, 64); - assert_eq!(s.height, 8192); - assert_eq!(s.samples, 1); - } - - #[test] - fn sanitize_keeps_in_range_values() { - let s = RenderSettings { - width: 1024, - height: 768, - samples: 4, - } - .sanitize(); - assert_eq!(s.width, 1024); - assert_eq!(s.height, 768); - assert_eq!(s.samples, 4); - } - - #[test] - fn pixel_count_matches() { - let s = RenderSettings { - width: 100, - height: 200, - samples: 1, - }; - assert_eq!(s.pixel_count(), 20000); - } - - #[test] - fn encode_render_png_round_trips_via_png_header() { - let s = RenderSettings { - width: 2, - height: 2, - samples: 1, - }; - let rgb = vec![ - 255u8, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, - ]; - let bytes = encode_render_png(&s, &rgb).expect("encodes"); - // PNG magic - assert_eq!(&bytes[..8], &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]); - // PNG must declare the intended dimensions right after IHDR. - assert_eq!(&bytes[16..20], &2u32.to_be_bytes()); - assert_eq!(&bytes[20..24], &2u32.to_be_bytes()); - } - - #[test] - fn encode_rejects_mismatched_buffer_size() { - let s = RenderSettings { - width: 2, - height: 2, - samples: 1, - }; - assert!(encode_render_png(&s, &[0u8; 3]).is_err()); - } - - #[test] - fn write_render_png_creates_file_and_represents_as_path() { - let s = RenderSettings { - width: 2, - height: 2, - samples: 1, - }; - let rgb = vec![ - 255u8, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, - ]; - let dir = std::env::temp_dir().join(format!("nigig_render_export_{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let out = dir.join("frame").to_string_lossy().to_string(); - let written = write_render_png(&s, &rgb, &out).unwrap(); - assert!(written.ends_with("frame.png")); - let disk = std::fs::read(&written).unwrap(); - assert_eq!(&disk[..8], &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]); - let _ = std::fs::remove_dir_all(&dir); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/script_parts.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/script_parts.rs deleted file mode 100644 index 6e9c8d4..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/script_parts.rs +++ /dev/null @@ -1,336 +0,0 @@ -//! # script_parts — decompose a script-evaluated `Solid` into editable parts. -//! -//! The CAD script evaluates to a single merged `Solid` (a flat `TriMesh` -//! with no per-primitive identity). The 3D viewport used to render that -//! single mesh directly, which left the 2D viewport (which only draws -//! `parts`) showing nothing for script-authored geometry. -//! -//! This module turns that merged mesh into connected components and -//! materialises each one as a `CadNode` part carrying a `CadSolid::Csg` -//! solid, so both 2D and 3D renderers draw script output uniformly. -//! -//! Each component is recentred around its own AABB centre so it behaves -//! like any other part (geometry centred at the origin, `translation` -//! holding its position) and the merged `TriMesh` is split into connected -//! pieces by shared triangle edges. -//! -//! Parts produced here are tagged with the `__script__` name prefix so -//! the parts→script serialiser can skip them (they were derived *from* -//! the script, so re-serialising them would fight the handwritten source -//! and re-enter the eval loop). - -use std::collections::HashMap; -use std::sync::Arc; - -use crate::makepad_csg::{Solid, TriMesh, Vec3d as CsgVec3}; -use makepad_widgets::{vec3, vec4, Vec3f, Vec4f}; - -use super::cad_scene::{ - CadNode, CadSolid, CadTransform, LayerId, MaterialId, NodeId, NodeMetadata, PartKind, -}; -use super::math::DVec3; - -/// Name prefix marking a part that was decomposed out of the script -/// solid. Mirrors the `__hidden__` convention used by `CadNode`. -pub const SCRIPT_PREFIX: &str = "__script__"; - -/// True when `name` marks a script-derived part. -pub fn is_script_bred(name: &str) -> bool { - name.starts_with(SCRIPT_PREFIX) -} - -/// One connected piece of the merged script solid. -#[derive(Clone, Debug)] -pub struct ScriptComponent { - /// AABB centre of the piece in model space; becomes the node's - /// `translation`. - pub center: DVec3, - /// The piece's geometry recentred so it is centred at the origin. - pub mesh: TriMesh, -} - -/// A disjoint-set forest with path compression, used to group triangles -/// that share edges into connected components. -struct Dsu { - parent: Vec, -} - -impl Dsu { - fn new(n: usize) -> Self { - Self { - parent: (0..n).collect(), - } - } - - fn find(&mut self, x: usize) -> usize { - let root = { - let mut r = x; - while self.parent[r] != r { - r = self.parent[r]; - } - r - }; - let mut cur = x; - while self.parent[cur] != cur { - let next = self.parent[cur]; - self.parent[cur] = root; - cur = next; - } - root - } - - fn union(&mut self, a: usize, b: usize) { - let ra = self.find(a); - let rb = self.find(b); - if ra != rb { - self.parent[ra] = rb; - } - } -} - -/// Split a triangle mesh into connected components. Two triangles are in -/// the same component when they share an edge (share two vertex indices). -/// -/// Returns one compact `TriMesh` per component, with vertex indices -/// remapped to the used subset. -pub fn split_into_components(mesh: &TriMesh) -> Vec { - let n_tri = mesh.triangles.len(); - if n_tri == 0 { - return Vec::new(); - } - let mut dsu = Dsu::new(n_tri); - // For each undirected edge, the first triangle that owns it. A second - // triangle hitting the same edge is welded to the first. - let mut edge_owner: HashMap<(u32, u32), usize> = HashMap::new(); - for (ti, tri) in mesh.triangles.iter().enumerate() { - for (a, b) in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] { - let key = if a < b { (a, b) } else { (b, a) }; - if let Some(&other) = edge_owner.get(&key) { - dsu.union(ti, other); - } else { - edge_owner.insert(key, ti); - } - } - } - // Group triangle indices by root, preserving first-seen order so - // output ordering is stable regardless of hash iteration. - let mut groups: HashMap> = HashMap::new(); - let mut roots: Vec = Vec::new(); - for ti in 0..n_tri { - let root = dsu.find(ti); - if !groups.contains_key(&root) { - roots.push(root); - } - groups.entry(root).or_default().push(ti); - } - roots - .into_iter() - .map(|root| extract_component(mesh, &groups[&root])) - .collect() -} - -fn extract_component(mesh: &TriMesh, tris: &[usize]) -> TriMesh { - let mut remap: HashMap = HashMap::new(); - let mut out = TriMesh::new(); - for &ti in tris { - let src = mesh.triangles[ti]; - let mut tri = [0u32; 3]; - for (k, v) in src.iter().enumerate() { - let idx = *remap.entry(*v).or_insert_with(|| { - let new = out.vertices.len() as u32; - out.vertices.push(mesh.vertices[*v as usize]); - new - }); - tri[k] = idx; - } - out.triangles.push(tri); - } - out -} - -/// Recentre a mesh around its own AABB centre. -/// -/// The merged script mesh is in absolute model coordinates, but a -/// `CadNode` part is geometry-centred-at-origin plus a `translation`. -/// Returning the centre lets callers place the part exactly where the -/// script put it while keeping the local geometry origin-centred. -pub fn recentre_component(mesh: &TriMesh) -> (TriMesh, DVec3) { - if mesh.vertices.is_empty() { - return (mesh.clone(), DVec3::default()); - } - let mut min = mesh.vertices[0]; - let mut max = mesh.vertices[0]; - for v in &mesh.vertices { - min = CsgVec3 { - x: min.x.min(v.x), - y: min.y.min(v.y), - z: min.z.min(v.z), - }; - max = CsgVec3 { - x: max.x.max(v.x), - y: max.y.max(v.y), - z: max.z.max(v.z), - }; - } - let center = DVec3 { - x: (min.x + max.x) * 0.5, - y: (min.y + max.y) * 0.5, - z: (min.z + max.z) * 0.5, - }; - let mut out = mesh.clone(); - for v in &mut out.vertices { - v.x -= center.x; - v.y -= center.y; - v.z -= center.z; - } - (out, center) -} - -/// Split a script solid into recentred connected components. -pub fn components_from_solid(solid: &Solid) -> Vec { - split_into_components(solid.mesh()) - .into_iter() - .map(|m| { - let (mesh, center) = recentre_component(&m); - ScriptComponent { center, mesh } - }) - .collect() -} - -/// Build a `CadNode` part from a script component. -/// -/// The solid is carried as `CadSolid::Csg` so it renders exactly as the -/// script produced it, `translation` holds the component centre, and the -/// `__script__` name marks it for exclusion from parts→script sync. -pub fn node_from_component(index: usize, id: NodeId, comp: &ScriptComponent) -> CadNode { - CadNode { - id, - name: format!("{}Script-{}", SCRIPT_PREFIX, index + 1), - solid: Some(CadSolid::Csg(Arc::new(Solid::from_mesh(comp.mesh.clone())))), - transform: CadTransform { - translation: Vec3f { - x: comp.center.x as f32, - y: comp.center.y as f32, - z: comp.center.z as f32, - }, - rotation_euler_xyz: Vec3f { - x: 0.0, - y: 0.0, - z: 0.0, - }, - scale: 1.0, - }, - material: MaterialId::ROOT, - layer: LayerId::ROOT, - parent: None, - metadata: NodeMetadata::default(), - color: vec4(0.62, 0.62, 0.66, 1.0), - kind_hint: Some(PartKind::Cube), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::makepad_csg::TriMesh; - - fn v3(x: f64, y: f64, z: f64) -> CsgVec3 { - CsgVec3 { x, y, z } - } - - fn tri(a: [u32; 3]) -> [u32; 3] { - a - } - - #[test] - fn split_handles_empty_mesh() { - let mesh = TriMesh::new(); - assert!(split_into_components(&mesh).is_empty()); - } - - #[test] - fn split_two_disjoint_triangles() { - let mesh = TriMesh { - vertices: vec![v3(0.0, 0.0, 0.0), v3(1.0, 0.0, 0.0), v3(0.0, 1.0, 0.0), v3(5.0, 0.0, 0.0), v3(6.0, 0.0, 0.0), v3(5.0, 1.0, 0.0)], - triangles: vec![tri([0, 1, 2]), tri([3, 4, 5])], - }; - let comps = split_into_components(&mesh); - assert_eq!(comps.len(), 2); - for c in &comps { - assert_eq!(c.triangle_count(), 1); - assert_eq!(c.vertex_count(), 3); - } - } - - #[test] - fn split_two_triangles_sharing_an_edge() { - // Two triangles share edge (1,2) -> one component of 2 triangles. - let mesh = TriMesh { - vertices: vec![v3(0.0, 0.0, 0.0), v3(1.0, 0.0, 0.0), v3(0.0, 1.0, 0.0), v3(1.0, 1.0, 0.0)], - triangles: vec![tri([0, 1, 2]), tri([1, 3, 2])], - }; - let comps = split_into_components(&mesh); - assert_eq!(comps.len(), 1); - assert_eq!(comps[0].triangle_count(), 2); - assert_eq!(comps[0].vertex_count(), 4); - } - - #[test] - fn split_triangle_strip_is_one_component() { - let mesh = TriMesh { - vertices: vec![v3(0.0, 0.0, 0.0), v3(1.0, 0.0, 0.0), v3(0.0, 1.0, 0.0), v3(1.0, 1.0, 0.0), v3(2.0, 1.0, 0.0)], - triangles: vec![tri([0, 1, 2]), tri([1, 3, 2]), tri([1, 4, 3])], - }; - let comps = split_into_components(&mesh); - assert_eq!(comps.len(), 1); - assert_eq!(comps[0].triangle_count(), 3); - } - - #[test] - fn recentre_returns_aabb_center_and_centred_geometry() { - let mesh = TriMesh { - vertices: vec![v3(2.0, 4.0, 6.0), v3(6.0, 4.0, 6.0), v3(2.0, 8.0, 6.0)], - triangles: vec![tri([0, 1, 2])], - }; - let (centred, center) = recentre_component(&mesh); - assert!((center.x - 4.0).abs() < 1e-9); - assert!((center.y - 6.0).abs() < 1e-9); - assert!((center.z - 6.0).abs() < 1e-9); - // Geometry centred at origin: min == -max. - let mut mn = centred.vertices[0]; - let mut mx = centred.vertices[0]; - for v in ¢red.vertices { - mn = v3(mn.x.min(v.x), mn.y.min(v.y), mn.z.min(v.z)); - mx = v3(mx.x.max(v.x), mx.y.max(v.y), mx.z.max(v.z)); - } - assert!((mn.x + 2.0).abs() < 1e-9); - assert!((mx.x - 2.0).abs() < 1e-9); - } - - #[test] - fn node_is_script_bred_with_centre_translation() { - let comp = ScriptComponent { - center: DVec3 { - x: 3.0, - y: 4.0, - z: 5.0, - }, - mesh: TriMesh::new(), - }; - let node = node_from_component(0, NodeId(42), &comp); - assert!(is_script_bred(&node.name)); - assert!(node.name.starts_with(SCRIPT_PREFIX)); - assert_eq!(node.id.raw(), 42); - assert!((node.transform.translation.x - 3.0).abs() < 1e-6); - assert!((node.transform.translation.y - 4.0).abs() < 1e-6); - assert!((node.transform.translation.z - 5.0).abs() < 1e-6); - assert_eq!(node.part_kind(), PartKind::Cube); - } - - #[test] - fn is_script_bred_negatives() { - assert!(!is_script_bred("Part-1")); - assert!(!is_script_bred("")); - assert!(!is_script_bred("__script")); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/section.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/section.rs deleted file mode 100644 index 728dcba..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/section.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Section planes: the CPU-side clip that lets the editor "see inside" the -//! model along an axis-aligned cut. -//! -//! A `SectionPlane` keeps everything on one side of a plane `dot(n, p) >= 0` -//! (equivalently `dot(n, p) <= offset` for `offset` in units of the distance -//! from the origin). Parts whose world AABB lies entirely *inside* the kept -//! half-space are drawn; parts entirely outside are dropped; parts that -//! straddle the plane stay (so the cut looks continuous across the boundary -//! without tessellation). -//! -//! Pure logic with no makepad types so it is unit-testable. - -/// An axis-aligned half-space cut: `dot(normal, p) <= offset`. -/// -/// `normal` is a unit vector (axis-aligned for our supported cuts) and -/// `offset` is a signed distance from the origin along `normal`. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct SectionPlane { - /// Unit normal along the cut axis. - pub normal: (f64, f64, f64), - /// Signed plane offset: points with `dot(normal, p) <= offset` are kept. - pub offset: f64, -} - -impl SectionPlane { - /// A plane through the world origin with the given unit normal. - pub fn through_origin(normal: (f64, f64, f64)) -> Self { - SectionPlane { normal, offset: 0.0 } - } - - /// Axis-aligned plane X = offset, keeping X <= offset. - pub fn axis_x(offset: f64) -> Self { - SectionPlane { normal: (1.0, 0.0, 0.0), offset } - } - - /// Axis-aligned plane Y = offset (horizontal cut), keeping Y <= offset. - pub fn axis_y(offset: f64) -> Self { - SectionPlane { normal: (0.0, 1.0, 0.0), offset } - } - - /// Axis-aligned plane Z = offset (plan cut), keeping Z <= offset. - pub fn axis_z(offset: f64) -> Self { - SectionPlane { normal: (0.0, 0.0, 1.0), offset } - } - - /// Flip the kept side by negating the normal and the offset. - pub fn flip(self) -> Self { - SectionPlane { - normal: (-self.normal.0, -self.normal.1, -self.normal.2), - offset: -self.offset, - } - } - - /// Move the plane by `delta` along its normal. - pub fn with_offset(self, delta: f64) -> Self { - SectionPlane { normal: self.normal, offset: self.offset - delta } - } - - /// True when `p` lies on the kept side of the plane. - pub fn contains(self, p: (f64, f64, f64)) -> bool { - let d = self.normal.0 * p.0 + self.normal.1 * p.1 + self.normal.2 * p.2; - d <= self.offset - } - - /// True when the whole AABB (`min`..`max`) is inside the kept half-space. - /// - /// The farthest kept corner along the normal is the one that minimizes - /// `dot(normal, corner)`; if even that corner is kept, all of it is. - pub fn kept(self, min: (f64, f64, f64), max: (f64, f64, f64)) -> bool { - // Corner with the smallest signed distance along `normal`: - let corner = ( - if self.normal.0 >= 0.0 { min.0 } else { max.0 }, - if self.normal.1 >= 0.0 { min.1 } else { max.1 }, - if self.normal.2 >= 0.0 { min.2 } else { max.2 }, - ); - self.contains(corner) - } -} - -/// Build the plane that goes through `p0` with the given unit `normal`, -/// solving for the offset so that `dot(normal, p0) = offset`. -pub fn plane_through(p0: (f64, f64, f64), normal: (f64, f64, f64)) -> SectionPlane { - let offset = normal.0 * p0.0 + normal.1 * p0.1 + normal.2 * p0.2; - SectionPlane { normal, offset } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn axis_planes_have_unit_normals() { - for p in [SectionPlane::axis_x(1.0), SectionPlane::axis_y(1.0), SectionPlane::axis_z(1.0)] { - let n2 = p.normal.0 * p.normal.0 + p.normal.1 * p.normal.1 + p.normal.2 * p.normal.2; - assert!((n2 - 1.0).abs() < 1e-9); - } - } - - #[test] - fn kept_respects_plane_side() { - let plane = SectionPlane::axis_z(0.0); // keep z <= 0 - // Box fully below the plane is kept. - assert!(plane.kept((0.0, 0.0, -2.0), (1.0, 1.0, -0.5))); - // Box fully above is dropped. - assert!(!plane.kept((0.0, 0.0, 0.5), (1.0, 1.0, 2.0))); - // Box straddling stays. - assert!(plane.kept((0.0, 0.0, -0.5), (1.0, 1.0, 0.5))); - } - - #[test] - fn kept_uses_farthest_corner_per_axis() { - // Keep x <= 5; min x is 3 so even the min corner is inside -> kept. - let plane = SectionPlane::axis_x(5.0); - assert!(plane.kept((3.0, 0.0, 0.0), (4.0, 0.0, 0.0))); - // Box entirely x > 5 dropped. - assert!(!plane.kept((6.0, 0.0, 0.0), (7.0, 0.0, 0.0))); - } - - #[test] - fn flip_keeps_the_other_side() { - let plane = SectionPlane::axis_z(0.0); - let flipped = plane.flip(); // keep z >= 0 - assert!(!plane.kept((0.0, 0.0, 1.0), (1.0, 1.0, 2.0))); - assert!(flipped.kept((0.0, 0.0, 1.0), (1.0, 1.0, 2.0))); - } - - #[test] - fn with_offset_moves_the_cut() { - // Keep x <= 0; moving + keeps x <= 2. - let plane = SectionPlane::axis_x(0.0).with_offset(-2.0); - assert_eq!(plane.offset, 2.0); - assert!(plane.kept((1.0, 0.0, 0.0), (1.5, 0.0, 0.0))); - } - - #[test] - fn plane_through_solves_offset() { - let plane = plane_through((2.0, 0.0, 0.0), (1.0, 0.0, 0.0)); - assert_eq!(plane.offset, 2.0); - assert!(plane.contains((2.0, 0.0, 0.0))); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/snap.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/snap.rs deleted file mode 100644 index 77add84..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/snap.rs +++ /dev/null @@ -1,494 +0,0 @@ -//! Snap system: BVH-accelerated snap-to-geometry with screen-space radius. -//! -//! Ported from `fab::tools::snap` and adapted to our f64 scene graph. -//! Replaces the O(n) linear-scan snap functions in viewport.rs with an -//! O(log n) BVH-based approach that works on actual mesh triangles -//! (not AABB bounding boxes). -//! -//! # Design decisions -//! -//! - **Screen-space radius**: `radius_px` replaces the old `snap_tolerance` -//! (world units). A 20px radius feels the same at any zoom level. -//! - **Priority chain**: Vertex > EdgeMidpoint > Edge > Face > Ground. -//! - **Face snap** is always-on as a fallback after raycast: if the ray -//! hits a triangle, that point is offered as a face candidate. -//! - **Ground fallback**: when the ray misses all geometry, it intersects -//! with the XZ ground plane (y=0). - -use crate::construction_frame::pages::workspace::cad::bvh::{Bvh, BvhPickOptions, BvhRay}; -use crate::construction_frame::pages::workspace::cad::math::{mat4_mul_vec4, DVec3}; -use makepad_widgets::makepad_math::*; -use makepad_widgets::DVec2; - -// ─── Snap types ───────────────────────────────────────────────────────── - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum SnapKind { - /// Snap to a triangle vertex. - Vertex, - /// Snap to a triangle edge midpoint. - EdgeMidpoint, - /// Snap to the closest point on a triangle edge. - Edge, - /// Snap to the closest point on a triangle face. - Face, - /// Snap to the ground plane (y=0 fallback). - Ground, -} - -impl SnapKind { - /// Numeric priority for tie-breaking (lower = higher priority). - pub fn priority(self) -> u8 { - match self { - Self::Vertex => 0, - Self::EdgeMidpoint => 1, - Self::Edge => 2, - Self::Face => 3, - Self::Ground => 4, - } - } - - pub fn label(self) -> &'static str { - match self { - Self::Vertex => "Vertex", - Self::EdgeMidpoint => "Midpoint", - Self::Edge => "Edge", - Self::Face => "Face", - Self::Ground => "Ground", - } - } -} - -#[derive(Clone, Copy, Debug)] -pub struct SnapHit { - pub kind: SnapKind, - pub point: DVec3, - pub element_id: u64, - pub normal: Option, - pub screen_dist: f64, -} - -impl SnapHit { - pub fn is_better_than(&self, other: &SnapHit) -> bool { - self.kind < other.kind - || (self.kind == other.kind && self.screen_dist < other.screen_dist) - } -} - -/// Which snap types are enabled, plus the screen-space search radius. -#[derive(Clone, Copy, Debug)] -pub struct SnapOptions { - pub vertex: bool, - pub edge_midpoint: bool, - pub edge: bool, - pub face: bool, - pub ground: bool, - /// Screen-space snap radius in pixels. - pub radius_px: f32, -} - -impl Default for SnapOptions { - fn default() -> Self { - Self { - vertex: true, - edge_midpoint: true, - edge: true, - face: true, - ground: true, - radius_px: 20.0, - } - } -} - -// ─── Per-element snap scan ────────────────────────────────────────────── - -/// Scan a single element's triangles for snap candidates. -/// -/// Given a node_id and its mesh, generate snap candidates near the -/// cursor position. This is called after the BVH identifies the element. -pub fn snap_element( - node_id: u64, - mesh_vertices: &[[f64; 3]], - mesh_triangles: &[[u32; 3]], - model: &Mat4f, - cursor: DVec2, - opts: &SnapOptions, - project_to_screen: impl Fn(DVec3) -> DVec2, - pixels_per_world: f64, - radius_px: f32, -) -> Vec { - let mut candidates = Vec::with_capacity(32); - let radius_world = radius_px as f64 * pixels_per_world; - - // Collect unique vertices (world space). - let mut seen_verts: std::collections::HashSet = std::collections::HashSet::new(); - - for tri in mesh_triangles { - // Transform vertices to world space. - let wv: [DVec3; 3] = [0, 1, 2].map(|i| { - let v = mesh_vertices[tri[i] as usize]; - let w = mat4_mul_vec4(model, [v[0] as f32, v[1] as f32, v[2] as f32, 1.0]); - DVec3 { x: w[0] as f64, y: w[1] as f64, z: w[2] as f64 } - }); - - let face_center = DVec3 { - x: (wv[0].x + wv[1].x + wv[2].x) / 3.0, - y: (wv[0].y + wv[1].y + wv[2].y) / 3.0, - z: (wv[0].z + wv[1].z + wv[2].z) / 3.0, - }; - - // Face normal (for metadata, not for snap distance). - let e1 = DVec3 { x: wv[1].x - wv[0].x, y: wv[1].y - wv[0].y, z: wv[1].z - wv[0].z }; - let e2 = DVec3 { x: wv[2].x - wv[0].x, y: wv[2].y - wv[0].y, z: wv[2].z - wv[0].z }; - let normal = DVec3 { - x: e1.y * e2.z - e1.z * e2.y, - y: e1.z * e2.x - e1.x * e2.z, - z: e1.x * e2.y - e1.y * e2.x, - }; - let normal_len = (normal.x * normal.x + normal.y * normal.y + normal.z * normal.z).sqrt(); - let normal_unit = if normal_len > 1e-12 { - DVec3 { x: normal.x / normal_len, y: normal.y / normal_len, z: normal.z / normal_len } - } else { - normal - }; - - // Face snap candidate (always offered). - if opts.face { - let sp = project_to_screen(face_center); - let dist = ((sp.x - cursor.x).powi(2) + (sp.y - cursor.y).powi(2)).sqrt(); - if dist <= radius_px as f64 { - candidates.push(SnapHit { - kind: SnapKind::Face, - point: face_center, - element_id: node_id, - normal: Some(normal_unit), - screen_dist: dist, - }); - } - } - - // Vertex snap candidates. - if opts.vertex { - for v in &wv { - // Use raw triangle index + vertex position as a pseudo-key. - let sp = project_to_screen(*v); - let dist = ((sp.x - cursor.x).powi(2) + (sp.y - cursor.y).powi(2)).sqrt(); - if dist <= radius_px as f64 { - candidates.push(SnapHit { - kind: SnapKind::Vertex, - point: *v, - element_id: node_id, - normal: Some(normal_unit), - screen_dist: dist, - }); - } - } - } - - // Edge midpoint candidates. - if opts.edge_midpoint { - for i in 0..3 { - let a = wv[i]; - let b = wv[(i + 1) % 3]; - let mid = DVec3 { - x: (a.x + b.x) * 0.5, - y: (a.y + b.y) * 0.5, - z: (a.z + b.z) * 0.5, - }; - let sp = project_to_screen(mid); - let dist = ((sp.x - cursor.x).powi(2) + (sp.y - cursor.y).powi(2)).sqrt(); - if dist <= radius_px as f64 { - candidates.push(SnapHit { - kind: SnapKind::EdgeMidpoint, - point: mid, - element_id: node_id, - normal: Some(normal_unit), - screen_dist: dist, - }); - } - } - } - - // Edge (closest point on edge) candidates. - if opts.edge { - for i in 0..3 { - let a = wv[i]; - let b = wv[(i + 1) % 3]; - let ab = DVec3 { x: b.x - a.x, y: b.y - a.y, z: b.z - a.z }; - let ab_len2 = ab.x * ab.x + ab.y * ab.y + ab.z * ab.z; - if ab_len2 < 1e-24 { - continue; - } - // Project cursor ray onto the edge to find closest point. - // Approximate: project screen cursor onto edge in screen space. - let sa = project_to_screen(a); - let sb = project_to_screen(b); - let sab = DVec2 { x: sb.x - sa.x, y: sb.y - sa.y }; - let sab_len2 = sab.x * sab.x + sab.y * sab.y; - if sab_len2 < 1e-12 { - continue; - } - let t = ((cursor.x - sa.x) * sab.x + (cursor.y - sa.y) * sab.y) / sab_len2; - let t_clamped = t.clamp(0.0, 1.0); - let closest = DVec3 { - x: a.x + ab.x * t_clamped, - y: a.y + ab.y * t_clamped, - z: a.z + ab.z * t_clamped, - }; - let sp = project_to_screen(closest); - let dist = ((sp.x - cursor.x).powi(2) + (sp.y - cursor.y).powi(2)).sqrt(); - if dist <= radius_px as f64 { - candidates.push(SnapHit { - kind: SnapKind::Edge, - point: closest, - element_id: node_id, - normal: Some(normal_unit), - screen_dist: dist, - }); - } - } - } - } - - candidates -} - -// ─── Ground snap ──────────────────────────────────────────────────────── - -/// Snap to the ground plane (y=0) as a fallback when geometry is missed. -pub fn snap_to_ground( - ray_origin: DVec3, - ray_dir: DVec3, - cursor: DVec2, - project_to_screen: impl Fn(DVec3) -> DVec2, - radius_px: f32, -) -> Option { - // Intersect ray with y=0 plane. - if ray_dir.y.abs() < 1e-12 { - return None; - } - let t = -ray_origin.y / ray_dir.y; - if t < 0.0 { - return None; - } - let point = DVec3 { - x: ray_origin.x + ray_dir.x * t, - y: 0.0, - z: ray_origin.z + ray_dir.z * t, - }; - let sp = project_to_screen(point); - let dist = ((sp.x - cursor.x).powi(2) + (sp.y - cursor.y).powi(2)).sqrt(); - if dist <= radius_px as f64 { - Some(SnapHit { - kind: SnapKind::Ground, - point, - element_id: 0, - normal: Some(DVec3 { x: 0.0, y: 1.0, z: 0.0 }), - screen_dist: dist, - }) - } else { - None - } -} - -// ─── Screen-space utilities ───────────────────────────────────────────── - -/// Convert a screen-space radius (pixels) to world-space distance at a -/// given depth from the camera. -pub fn pixels_to_world(radius_px: f32, pixels_per_world: f64) -> f64 { - radius_px as f64 / pixels_per_world -} - -/// Compute pixels-per-world-unit from camera parameters. -/// -/// For perspective: `2 * distance * tan(fov_y/2) / viewport_height`. -/// For orthographic: `viewport_height / (2 * ortho_height)`. -pub fn pixels_per_world_perspective( - distance: f32, - fov_y: f32, - viewport_height: f32, -) -> f32 { - let half_fov = fov_y * 0.5; - let world_height = 2.0 * distance * half_fov.tan(); - viewport_height / world_height -} - -pub fn pixels_per_world_ortho( - ortho_height: f32, - viewport_height: f32, -) -> f32 { - viewport_height / (2.0 * ortho_height) -} - -// ─── BVH snap extension ───────────────────────────────────────────────── - -impl Bvh { - /// BVH-accelerated snap: find the best snap candidate near `cursor`. - /// - /// This combines a BVH raycast with per-element triangle scanning. - /// The `lookup_element` callback provides triangle data for a given - /// node_id. - pub fn snap( - &self, - cursor: DVec2, - opts: &SnapOptions, - screen_to_ray: impl Fn(DVec2) -> Option<(DVec3, DVec3)>, - project_to_screen: impl Fn(DVec3) -> DVec2, - lookup_element: impl Fn(u64) -> Option<(Vec<[f64; 3]>, Vec<[u32; 3]>, Mat4f)>, - pixels_per_world: f64, - ) -> Option { - let (ray_origin, ray_dir) = screen_to_ray(cursor)?; - - // Broadphase: use BVH element bounds to find candidate elements - // near the cursor, then narrowphase with per-element triangle scanning. - let mut candidates: Vec = Vec::with_capacity(64); - - for &(elem_id, ref aabb) in self.element_bounds() { - // Broadphase: project AABB to screen and check distance. - let corners = [ - [aabb.min[0], aabb.min[1], aabb.min[2]], - [aabb.max[0], aabb.min[1], aabb.min[2]], - [aabb.min[0], aabb.max[1], aabb.min[2]], - [aabb.max[0], aabb.max[1], aabb.min[2]], - [aabb.min[0], aabb.min[1], aabb.max[2]], - [aabb.max[0], aabb.min[1], aabb.max[2]], - [aabb.min[0], aabb.max[1], aabb.max[2]], - [aabb.max[0], aabb.max[1], aabb.max[2]], - ]; - let mut min_screen_dist = f64::INFINITY; - for corner in &corners { - let p = DVec3 { x: corner[0], y: corner[1], z: corner[2] }; - let sp = project_to_screen(p); - let d = ((sp.x - cursor.x).powi(2) + (sp.y - cursor.y).powi(2)).sqrt(); - min_screen_dist = min_screen_dist.min(d); - } - // Expanded radius: if AABB is anywhere near the cursor, scan it. - let expanded_radius = opts.radius_px as f64 * 3.0; // generous broadphase - if min_screen_dist > expanded_radius { - continue; - } - - // Narrowphase: look up the actual mesh triangles. - if let Some((vertices, triangles, model)) = lookup_element(elem_id) { - let hits = snap_element( - elem_id, - &vertices, - &triangles, - &model, - cursor, - opts, - &project_to_screen, - pixels_per_world, - opts.radius_px, - ); - candidates.extend(hits); - } - } - - // Step 2: ground fallback. - if candidates.is_empty() && opts.ground { - if let Some(ground_hit) = snap_to_ground( - ray_origin, - ray_dir, - cursor, - &project_to_screen, - opts.radius_px, - ) { - candidates.push(ground_hit); - } - } - - // Step 3: pick best by priority, then screen distance. - candidates.into_iter().min_by(|a, b| { - a.kind.cmp(&b.kind) - .then_with(|| a.screen_dist.partial_cmp(&b.screen_dist).unwrap()) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn snap_kind_priority_ordering() { - assert!(SnapKind::Vertex < SnapKind::EdgeMidpoint); - assert!(SnapKind::EdgeMidpoint < SnapKind::Edge); - assert!(SnapKind::Edge < SnapKind::Face); - assert!(SnapKind::Face < SnapKind::Ground); - } - - #[test] - fn snap_hit_comparison() { - let a = SnapHit { - kind: SnapKind::Vertex, - point: DVec3 { x: 0.0, y: 0.0, z: 0.0 }, - element_id: 1, - normal: None, - screen_dist: 10.0, - }; - let b = SnapHit { - kind: SnapKind::Vertex, - point: DVec3 { x: 1.0, y: 0.0, z: 0.0 }, - element_id: 2, - normal: None, - screen_dist: 5.0, - }; - assert!(b.is_better_than(&a)); // same kind, closer screen dist. - - let c = SnapHit { - kind: SnapKind::Edge, - point: DVec3 { x: 0.0, y: 0.0, z: 0.0 }, - element_id: 3, - normal: None, - screen_dist: 1.0, - }; - assert!(a.is_better_than(&c)); // vertex beats edge even if farther. - } - - #[test] - fn snap_to_ground_basic() { - let origin = DVec3 { x: 0.0, y: 5.0, z: 0.0 }; - let dir = DVec3 { x: 0.0, y: -1.0, z: 0.0 }; - let project = |p: DVec3| DVec2 { x: p.x, y: p.z }; // simple projection - let hit = snap_to_ground(origin, dir, DVec2 { x: 0.0, y: 0.0 }, project, 20.0); - assert!(hit.is_some()); - let hit = hit.unwrap(); - assert_eq!(hit.kind, SnapKind::Ground); - assert!((hit.point.y).abs() < 1e-10); - } - - #[test] - fn snap_to_ground_parallel_ray_misses() { - let origin = DVec3 { x: 0.0, y: 5.0, z: 0.0 }; - let dir = DVec3 { x: 1.0, y: 0.0, z: 0.0 }; // parallel to ground - let project = |p: DVec3| DVec2 { x: p.x, y: p.z }; - let hit = snap_to_ground(origin, dir, DVec2 { x: 0.0, y: 0.0 }, project, 20.0); - assert!(hit.is_none()); - } - - #[test] - fn snap_to_ground_too_far() { - let origin = DVec3 { x: 0.0, y: 5.0, z: 0.0 }; - let dir = DVec3 { x: 1.0, y: -0.1, z: 0.0 }; // nearly horizontal, hits far away - let project = |p: DVec3| DVec2 { x: p.x * 10.0, y: p.z * 10.0 }; // huge scale - let hit = snap_to_ground(origin, dir, DVec2 { x: 0.0, y: 0.0 }, project, 20.0); - // Ground point would be at x=50, y=0, z=0 — projected to (500,0), far from cursor. - assert!(hit.is_none()); - } - - #[test] - fn pixels_per_world_perspective_calc() { - let ppw = pixels_per_world_perspective(10.0, std::f32::consts::FRAC_PI_4, 600.0); - // At distance 10, fov 45°, height 600: world_height = 2*10*tan(22.5°) ≈ 8.28 - // ppw = 600/8.28 ≈ 72.5 - assert!((ppw - 72.5).abs() < 1.0); - } - - #[test] - fn pixels_per_world_ortho_calc() { - let ppw = pixels_per_world_ortho(10.0, 600.0); - // ppw = 600/20 = 30 - assert!((ppw - 30.0).abs() < 0.1); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/sun.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/sun.rs deleted file mode 100644 index d56b597..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/sun.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! Sun study: a pure NOAA-style solar-position model that turns a -//! location/date/time into a compass azimuth and elevation, plus helpers to -//! name the compass point and build a unit light-direction vector. -//! -//! No makepad types (the direction vector is a plain `(f32, f32, f32)`), so -//! the astronomy is unit-testable in isolation. - -/// Where/when to compute the sun. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct SunSettings { - /// Latitude, decimal degrees, north positive. - pub latitude: f64, - /// Longitude, decimal degrees, east positive. - pub longitude: f64, - /// Calendar date as `(month, day)`, 1-based. - pub date: (u32, u32), - /// Decimal hour in UTC (0.0..24.0). - pub hour: f64, -} - -impl Default for SunSettings { - fn default() -> Self { - SunSettings { - latitude: 40.7, - longitude: -74.0, - date: (6, 21), - hour: 12.0, - } - } -} - -/// Day of year (1..366) for a `(month, day)` date (Gregorian, non-leap -/// approximation used by the NOAA model). -pub fn day_of_year(month: u32, day: u32) -> u32 { - const CUM: [u32; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; - (CUM[(month.saturating_sub(1) % 12) as usize] + day).min(366) -} - -/// Solar declination in radians for a day-of-year (NOAA empirical series). -fn declination_rad(doy: u32, gamma: f64) -> f64 { - 0.006918 - - 0.399912 * (gamma).cos() - + 0.070257 * (gamma).sin() - - 0.006758 * (2.0 * gamma).cos() - + 0.000907 * (2.0 * gamma).sin() - - 0.002697 * (3.0 * gamma).cos() - + 0.00148 * (3.0 * gamma).sin() -} - -/// Equation-of-time minutes for a day-of-year (NOAA series). -fn equation_of_time(doy: u32, gamma: f64) -> f64 { - let _ = doy; - 229.18 * (0.000075 - + 0.001868 * (gamma).cos() - - 0.032077 * (gamma).sin() - - 0.014615 * (2.0 * gamma).cos() - - 0.040849 * (2.0 * gamma).sin()) -} - -/// NOAA-style solar position. -/// -/// Returns `(azimuth_deg, elevation_deg)`: azimuth measured clockwise from -/// true north (0 = N, 90 = E), elevation above the horizon (negative = sun -/// below the horizon). -pub fn solar_position(settings: &SunSettings) -> (f64, f64) { - let doy = day_of_year(settings.date.0, settings.date.1); - let gamma = std::f64::consts::TAU / 365.0 - * (doy as f64 - 1.0 + (settings.hour - 12.0) / 24.0); - - let decl = declination_rad(doy, gamma); - let eqtime = equation_of_time(doy, gamma); - - // Time offset minutes: equation of time + 4 min per degree of east - // longitude (we ignore time zone, using UTC `hour`). - let time_offset = eqtime + 4.0 * settings.longitude; - let true_solar_time = settings.hour * 60.0 + time_offset; - // Solar hour angle (degrees); 0 at solar noon. - let hour_angle = true_solar_time / 4.0 - 180.0; - - let lat = settings.latitude.to_radians(); - let ha = hour_angle.to_radians(); - let cos_zenith = - lat.sin() * decl.sin() + lat.cos() * decl.cos() * ha.cos(); - let zenith = cos_zenith.clamp(-1.0, 1.0).acos(); - let elevation = 90.0 - zenith.to_degrees(); - - // Azimuth from north, clockwise (compass convention). - let el_rad = elevation.to_radians(); - let az_cos = ((decl.sin() * lat.cos() - decl.cos() * lat.sin() * ha.cos()) - / el_rad.cos()) - .clamp(-1.0, 1.0); - let az_from_north = az_cos.acos().to_degrees(); - let azimuth = if hour_angle > 0.0 { - 360.0 - az_from_north - } else { - az_from_north - }; - (normalize_azimuth(azimuth), elevation) -} - -fn normalize_azimuth(a: f64) -> f64 { - let mut a = a % 360.0; - if a < 0.0 { - a += 360.0; - } - a -} - -/// Compass point name for an azimuth in degrees (0 = N, clockwise). -pub fn compass_point(azimuth_deg: f64) -> &'static str { - const NAMES: [&str; 8] = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; - let az = normalize_azimuth(azimuth_deg); - let idx = ((az + 22.5) / 45.0) as usize % 8; - NAMES[idx] -} - -/// Unit vector pointing *toward the sun* in scene space, from compass -/// azimuth/elevation. Compass 0 = north maps to +Z, 90 = east maps to +X, -/// elevation up is +Y. -pub fn direction(azimuth_deg: f64, elevation_deg: f64) -> (f32, f32, f32) { - let az = azimuth_deg.to_radians(); - let el = elevation_deg.to_radians(); - let x = (el.cos() * az.sin()) as f32; - let y = el.sin() as f32; - let z = (el.cos() * az.cos()) as f32; - (x, y, z) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn day_of_year_is_sequential() { - assert_eq!(day_of_year(1, 1), 1); - assert_eq!(day_of_year(1, 15), 15); - assert_eq!(day_of_year(6, 21), 172); - assert_eq!(day_of_year(12, 31), 365); - } - - #[test] - fn elevation_positive_at_noon_in_june() { - // ~Lat 40N, Greenwich-ish, solar noon on the summer solstice. - let s = SunSettings { - latitude: 40.7, - longitude: 0.0, - date: (6, 21), - hour: 12.0, - }; - let (_az, el) = solar_position(&s); - // High sun: ~73°; comfortably positive and large. - assert!(el > 60.0, "noon june elevation was {el}"); - } - - #[test] - fn elevation_negative_at_midnight() { - let s = SunSettings { - latitude: 40.7, - longitude: 0.0, - date: (6, 21), - hour: 0.0, - }; - let (_az, el) = solar_position(&s); - assert!(el < 0.0, "midnight elevation was {el}"); - } - - #[test] - fn zimnoon_elevation_positive_but_lower_in_december() { - let s = SunSettings { - latitude: 40.7, - longitude: 0.0, - date: (12, 21), - hour: 12.0, - }; - let (_az, el) = solar_position(&s); - assert!(el > 0.0 && el < 50.0, "winter noon elevation was {el}"); - } - - #[test] - fn declination_stays_within_bounds() { - for doy in [1, 80, 172, 266, 355] { - let gamma = std::f64::consts::TAU / 365.0 * (doy as f64 - 1.0); - let d = declination_rad(doy, gamma).to_degrees(); - assert!(d.abs() <= 23.5, "declination {d} for doy {doy}"); - let _ = equation_of_time(doy, gamma); - } - } - - #[test] - fn compass_point_names() { - assert_eq!(compass_point(0.0), "N"); - assert_eq!(compass_point(90.0), "E"); - assert_eq!(compass_point(180.0), "S"); - assert_eq!(compass_point(270.0), "W"); - assert_eq!(compass_point(45.0), "NE"); - assert_eq!(compass_point(-90.0), "W"); - assert_eq!(compass_point(360.0), "N"); - } - - #[test] - fn direction_is_unit_and_oriented() { - let (x, y, z) = direction(90.0, 45.0); // east, 45° up - let m = (x * x + y * y + z * z).sqrt(); - assert!((m - 1.0).abs() < 1e-5); - assert!(y > 0.3, "elevation should lift y"); - assert!(x > 0.3, "east azimuth should push +x"); - // South+level faces -z. - let (x, _, z) = direction(180.0, 0.0); - assert!(z < 0.0, "south should be -z, got {z}"); - assert!(x.abs() < 1e-5); - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/viewport.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/viewport.rs index d4a54d6..568d43e 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/viewport.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/viewport.rs @@ -15,8 +15,6 @@ use std::time::Instant; // makepad Vec3d alias. Our DVec3 (from math.rs) has the methods the // viewport code expects (length, normalize, Mul, etc.). use super::math::{ray_aabb_intersect, ray_triangle_intersect, DVec3}; -use super::measure::MeasureKind; -use super::script_parts::{components_from_solid, is_script_bred, node_from_component, ScriptComponent}; impl CadRenderMode { pub(crate) fn from_index(index: usize) -> Self { @@ -88,30 +86,6 @@ impl CadViewport { stats } - /// Replace the document's script-derived parts with a fresh - /// decomposition of the latest evaluated script solid. - /// - /// Runs on the UI thread when a rebuild lands. Removes every part - /// tagged with the `__script__` prefix and pushes one new part per - /// connected component of the solid, so script output renders in both - /// 2D and 3D and stays in sync with the editor. User-created parts - /// (which are not script-tagged) are left untouched. - pub(crate) fn replace_script_parts(&mut self, cx: &mut Cx, comps: &[ScriptComponent]) { - if comps.is_empty() { - return; - } - let doc = self.doc.clone(); - let mut guard = doc.borrow_mut(); - let parts = guard.parts_mut(); - parts.retain(|n| !is_script_bred(&n.name)); - for (i, comp) in comps.iter().enumerate() { - let id = self.part_ids.allocate(); - parts.push(node_from_component(i, NodeId(id), comp)); - } - self.scene_cache.mark_dirty(); - self.area.redraw(cx); - } - /// Switch to the other view mode and return the new one. /// /// This is `set_view_mode` with the target computed rather than @@ -154,37 +128,6 @@ impl CadViewport { self.area.redraw(cx); } - /// Toggle between orthographic and perspective projection. - pub(crate) fn toggle_ortho(&mut self, cx: &mut Cx) { - let new_ortho = !self.ortho_enabled; - let fov_y = self.camera.fov_y; - crate::construction_frame::pages::workspace::cad::camera_orbit::set_ortho( - &mut self.camera, - &mut self.ortho_enabled, - &mut self.ortho_height, - new_ortho, - fov_y, - ); - self.area.redraw(cx); - } - - /// Jump to a preset camera view (Front, Back, Left, Right, Top, Bottom, Isometric). - pub(crate) fn set_preset_view( - &mut self, - cx: &mut Cx, - preset: crate::construction_frame::pages::workspace::cad::camera_orbit::PresetView, - ) { - let fov_y = self.camera.fov_y; - crate::construction_frame::pages::workspace::cad::camera_orbit::apply_preset( - &mut self.camera, - &mut self.ortho_enabled, - &mut self.ortho_height, - preset, - fov_y, - ); - self.area.redraw(cx); - } - /// Read access to the shared parts list. /// /// Returns a guard, not a reference to a field: the list lives in @@ -683,7 +626,6 @@ impl CadViewport { // v18b: add_part_command both records on the undo stack AND // invalidates caches (mark_dirty + clear_mesh_cache). The self.selection = vec![id]; - self.mark_selection_dirty(); self.add_part_command(id); self.script_dirty = true; self.area.redraw(cx); @@ -692,7 +634,6 @@ impl CadViewport { pub(crate) fn delete_selected(&mut self, cx: &mut Cx) { let ids: Vec = self.selection.drain(..).collect(); - self.mark_selection_dirty(); let mut any_deleted = false; for id in &ids { // Resolve the index, then drop the read guard before taking @@ -1079,9 +1020,6 @@ impl CadViewport { let (mut mxx, mut mxy, mut mxz) = (f64::MIN, f64::MIN, f64::MIN); let doc = self.document(); for part in CadViewport::read_parts(&doc).iter() { - if part.is_hidden() { - continue; - } let (hx, hy, hz) = ( part.size().x as f64 * 0.5, part.size().y as f64 * 0.5, @@ -1126,61 +1064,6 @@ impl CadViewport { self.area.redraw(cx); } - /// Frame the camera on the current selection; falls back to fit-all when - /// nothing is selected. - pub(crate) fn frame_selection(&mut self, cx: &mut Cx) { - if self.selection.is_empty() { - self.zoom_to_fit(cx); - return; - } - let (mut mnx, mut mny, mut mnz) = (f64::MAX, f64::MAX, f64::MAX); - let (mut mxx, mut mxy, mut mxz) = (f64::MIN, f64::MIN, f64::MIN); - let doc = self.document(); - for part in CadViewport::read_parts(&doc).iter() { - if !self.selection.contains(&part.id.raw()) || part.is_hidden() { - continue; - } - let (hx, hy, hz) = ( - part.size().x as f64 * 0.5, - part.size().y as f64 * 0.5, - part.size().z as f64 * 0.5, - ); - let (px, py, pz) = ( - part.pos().x as f64, - part.pos().y as f64, - part.pos().z as f64, - ); - mnx = mnx.min(px - hx); - mny = mny.min(py - hy); - mnz = mnz.min(pz - hz); - mxx = mxx.max(px + hx); - mxy = mxy.max(py + hy); - mxz = mxz.max(pz + hz); - } - let center = DVec3 { - x: (mnx + mxx) * 0.5, - y: (mny + mxy) * 0.5, - z: (mnz + mxz) * 0.5, - }; - let extent = (mxx - mnx).max(mxy - mny).max(mxz - mnz) * 0.5; - let fov_y = 42.0_f64.to_radians(); - let mut dist = extent / (fov_y * 0.5).tan() * 1.25; - dist = dist.clamp(self.camera.distance_min as f64, self.camera.distance_max as f64); - self.camera.desktop_target = Vec3f { - x: center.x as f32, - y: center.y as f32, - z: center.z as f32, - }; - self.camera.distance = dist as f32; - self.ortho_zoom = (extent as f32 * 1.35).max(0.25); - self.pan_2d = DVec2 { - x: center.x, - y: center.z, - }; - self.view_dirty = true; - self.area.redraw(cx); - } - pub(crate) fn project_point(&self, p: [f32; 4]) -> Option<(f64, f64)> { let view = mat4_mul_vec4(&self.last_view, p); let clip = mat4_mul_vec4(&self.last_proj, view); @@ -1200,9 +1083,6 @@ impl CadViewport { let mut best: Option<(f64, u64)> = None; let doc = self.document(); for part in CadViewport::read_parts(&doc).iter() { - if part.is_hidden() { - continue; - } let p = self.part_to_plane_2d(part); let s = self.view_to_screen_2d(p); let d = ((s.x - abs.x).powi(2) + (s.y - abs.y).powi(2)).sqrt(); @@ -1212,85 +1092,128 @@ impl CadViewport { } return best.map(|(_, id)| id); } - // 3D picking: use BVH acceleration for O(log n) ray-triangle tests. + // 3D picking: cast a ray from the click point through the scene. + // For each part, get its mesh from MeshCache, transform vertices + // to world space using the model matrix, then test the ray against + // every triangle using Moller–Trumbore. Keep the hit with the + // smallest positive t parameter. let (ray_origin, ray_dir) = match self.screen_to_ray_3d(abs) { Some(r) => r, None => return None, }; let mesh_cache = self.scene_cache.mesh_cache(); + let mut best: Option<(f64, u64)> = None; let doc = self.document(); - - // Lazily (re)build the BVH when the parts store generation changes. - { - let parts = CadViewport::read_parts(&doc); - let generation = parts.generation(); - let mut cached = self.pick_bvh.0.borrow_mut(); - if cached.as_ref().map_or(true, |(gen, _)| *gen != generation) { - // Collect Arc first to keep them alive for the - // reference-based Bvh::build call below. - // Explode amount snapshot: the closures below must not borrow - // `self`, so copy the view state before the borrow. - let explode_amount = self.explode_amount; - let collected: Vec<(u64, Arc, Mat4f)> = parts - .iter() - .enumerate() - .filter_map(|(idx, part)| { - if part.is_hidden() { - return None; - } - let mesh = mesh_cache.get_or_build(part); - if mesh.triangles.is_empty() { - return None; - } - let model = apply_explode(part_model_matrix(part), idx, explode_amount); - Some((part.id.raw(), mesh, model)) - }) - .collect(); - let build_input: Vec<(u64, &TriMesh, &Mat4f)> = collected - .iter() - .map(|(id, mesh, mat)| (*id, mesh.as_ref(), mat)) - .collect(); - let new_bvh = super::bvh::Bvh::build(&build_input); - *cached = Some((generation, new_bvh)); + for part in CadViewport::read_parts(&doc).iter() { + let model = part_model_matrix(part); + let mesh = mesh_cache.get_or_build(part); + if mesh.triangles.is_empty() { + continue; } - } + // Compute the world-space AABB from the mesh we already hold. + // + // This used to call `part.size()`, which for CSG and extruded + // solids has no closed form and therefore *builds the whole + // mesh again* just to read its bounds — 821ns versus 2ns for a + // parametric solid, paid per part on every mouse-move. `mesh` + // is right here, so take the bounds directly. + // + // Using the real mesh bounds is also more accurate: `size()` + // returns a symmetric extent about the origin, which + // over-estimates the box for any solid whose geometry is not + // centred. + // Phase 3.2: cache the world AABB per (geometry, placement). + // + // Transforming 8 corners by the model matrix and taking a + // min/max is cheap per part but paid for EVERY part on every + // pick, and hover picking runs on mouse-move. Measured at + // 238 us/frame for 500 parts against 39 us for a keyed + // lookup -- 6.2x, see + // `bench_pick_broadphase_world_aabb_recompute_vs_cache`. + // + // Keyed on `PlacedHash`, not `ParamHash`: this value is + // world-space, so it is stale the moment the part moves. + // + // Phase 1 of the render plan: the eight-corner transform + // moved to `cull::world_aabb_from_local_bounds`, which the + // 3D draw loop now calls too. Two copies would be two + // chances to disagree about a part's bounds -- and picking a + // part the renderer culled is precisely the bug that + // disagreement would produce. + let aabb = self.scene_cache.world_aabb_for(part, |node| { + let local_bbox = mesh.bounding_box(); + let (lo, hi) = (local_bbox.min, local_bbox.max); + super::cull::world_aabb_from_local_bounds( + DVec3 { + x: lo.x, + y: lo.y, + z: lo.z, + }, + DVec3 { + x: hi.x, + y: hi.y, + z: hi.z, + }, + &part_model_matrix(node), + ) + }); + let ws_min = DVec3 { + x: aabb.min[0], + y: aabb.min[1], + z: aabb.min[2], + }; + let ws_max = DVec3 { + x: aabb.max[0], + y: aabb.max[1], + z: aabb.max[2], + }; - // Raycast through the BVH. - let bvh_borrow = self.pick_bvh.0.borrow(); - let Some((_, ref bvh)) = *bvh_borrow else { - return None; - }; - let bvh_ray = super::bvh::BvhRay::new(ray_origin, ray_dir); - let explode_amount = self.explode_amount; - let triangle_at = |node_id: u64, tri_idx: u32| -> (DVec3, DVec3, DVec3) { - let parts = CadViewport::read_parts(&doc); - for (idx, part) in parts.iter().enumerate() { - if part.id.raw() == node_id { - let mesh = mesh_cache.get_or_build(part); - let model = apply_explode(part_model_matrix(part), idx, explode_amount); - let (a, b, c) = mesh.triangle_vertices(tri_idx as usize); - let wt = |v: crate::makepad_csg::Vec3d| -> DVec3 { - let w = mat4_mul_vec4( - &model, - [v.x as f32, v.y as f32, v.z as f32, 1.0], - ); - DVec3 { - x: w[0] as f64, - y: w[1] as f64, - z: w[2] as f64, - } - }; - return (wt(a), wt(b), wt(c)); + // AABB early-out. + if ray_aabb_intersect(ray_origin, ray_dir, ws_min, ws_max).is_none() { + continue; + } + // Test each triangle in the mesh. + for tri in &mesh.triangles { + let vi = tri[0] as usize; + let vj = tri[1] as usize; + let vk = tri[2] as usize; + if vi >= mesh.vertices.len() + || vj >= mesh.vertices.len() + || vk >= mesh.vertices.len() + { + continue; + } + let lv0 = mesh.vertices[vi]; + let lv1 = mesh.vertices[vj]; + let lv2 = mesh.vertices[vk]; + let to_dvec3 = |v: crate::makepad_csg::Vec3d| DVec3 { + x: v.x, + y: v.y, + z: v.z, + }; + let w0 = to_dvec3(lv0); + let w1 = to_dvec3(lv1); + let w2 = to_dvec3(lv2); + // Transform local-space vertices to world space. + let wt = |v: DVec3| -> DVec3 { + let w = mat4_mul_vec4(&model, [v.x as f32, v.y as f32, v.z as f32, 1.0]); + DVec3 { + x: w[0] as f64, + y: w[1] as f64, + z: w[2] as f64, + } + }; + let wv0 = wt(w0); + let wv1 = wt(w1); + let wv2 = wt(w2); + if let Some(t) = ray_triangle_intersect(ray_origin, ray_dir, wv0, wv1, wv2) { + if best.map_or(true, |(bt, _)| t < bt) { + best = Some((t, part.id.raw())); + } } } - unreachable!("BVH referenced node_id not in parts") - }; - let hit = bvh.raycast( - &bvh_ray, - &super::bvh::BvhPickOptions::default(), - triangle_at, - ); - hit.map(|h| h.node_id) + } + best.map(|(_, id)| id) } pub(crate) fn world_per_pixel(&self) -> f64 { @@ -1517,7 +1440,6 @@ impl CadViewport { // index so the delete command can restore it on undo. self.delete_part_command(part.id.raw(), part.clone(), old_idx); self.selection = new_ids; - self.mark_selection_dirty(); // Structural edit (added N parts, removed 1). The additions need // no invalidation; only the removed node's entry must go. self.mark_scene_dirty(); @@ -1601,7 +1523,6 @@ impl CadViewport { new_ids.push(id); } self.selection = new_ids.clone(); - self.mark_selection_dirty(); // Paste is a pure addition: add_part_command recorded undo for // each part, and new ids have no cache entry to invalidate. Only // the scene snapshot needs rebuilding. @@ -1648,52 +1569,15 @@ impl CadViewport { pub(crate) fn select_all(&mut self, cx: &mut Cx) { let ids: Vec = self.parts().iter().map(|p| p.id.raw()).collect(); self.selection = ids; - self.mark_selection_dirty(); self.area.redraw(cx); } /// Deselect all parts. pub(crate) fn deselect_all(&mut self, cx: &mut Cx) { self.selection.clear(); - self.mark_selection_dirty(); self.area.redraw(cx); } - /// Compose a human-readable properties readout for the current - /// selection, for display in a status bar or properties panel. - /// - /// Returns the raw readout (empty when nothing is selected); the - /// caller may prepend `properties::no_selection_hint()` when empty. - pub(crate) fn properties_summary(&self) -> String { - let doc = self.document(); - let parts = CadViewport::read_parts(&doc); - let selected: Vec<&CadNode> = self - .selection - .iter() - .filter_map(|sid| parts.iter().find(|p| p.id.raw() == *sid)) - .collect(); - super::properties::selection_properties(&selected) - } - - /// Flag that the selection changed this frame so the workspace can - /// refresh the properties readout in the status bar. Called after any - /// mutation of `self.selection`. - pub(crate) fn mark_selection_dirty(&mut self) { - self.selection_dirty = true; - } - - /// Read-and-clear the selection dirty flag (mirrors `take_view_dirty`). - pub(crate) fn take_selection_dirty(&mut self) -> bool { - let d = self.selection_dirty; - self.selection_dirty = false; - d - } - - /// The currently selected part ids (a copy). - pub(crate) fn selection_ids(&self) -> Vec { - self.selection.clone() - } - /// Isolate: hide all non-selected parts by toggling their visibility. /// If already in isolate mode, restores all parts. pub(crate) fn isolate_selected(&mut self, cx: &mut Cx) { @@ -1701,22 +1585,28 @@ impl CadViewport { return; } // Check if we're already isolating: if any non-selected part - // is hidden, we're in isolate mode. + // has name starting with "__hidden__", we're in isolate mode. let was_isolating = self .parts() .iter() - .any(|p| !self.selection.contains(&p.id.raw()) && p.is_hidden()); + .any(|p| !self.selection.contains(&p.id.raw()) && p.name.starts_with("__hidden__")); if was_isolating { - // Restore: unhide all parts. + // Restore: rename all __hidden__ parts back for p in self.parts_mut().iter_mut() { - p.set_hidden(false); + if p.name.starts_with("__hidden__") { + p.name = p + .name + .strip_prefix("__hidden__") + .unwrap_or(&p.name) + .to_string(); + } } } else { - // Hide: hide all non-selected parts. + // Hide: prefix non-selected part names with __hidden__ let selection = self.selection.clone(); for p in self.parts_mut().iter_mut() { - if !selection.contains(&p.id.raw()) { - p.set_hidden(true); + if !selection.contains(&p.id.raw()) && !p.name.starts_with("__hidden__") { + p.name = format!("__hidden__{}", p.name); } } } @@ -1724,206 +1614,6 @@ impl CadViewport { self.area.redraw(cx); } - /// Hide every part. - pub(crate) fn hide_all(&mut self, cx: &mut Cx) { - for p in self.parts_mut().iter_mut() { - p.set_hidden(true); - } - if !self.selection.is_empty() { - self.selection.clear(); - self.mark_selection_dirty(); - } - self.mark_scene_dirty(); - self.area.redraw(cx); - } - - /// Un-hide every part. - pub(crate) fn show_all(&mut self, cx: &mut Cx) { - for p in self.parts_mut().iter_mut() { - p.set_hidden(false); - } - self.mark_scene_dirty(); - self.area.redraw(cx); - } - - /// Toggle the visibility of a single part (used by the outliner). - pub(crate) fn toggle_part_visibility(&mut self, cx: &mut Cx, id: u64) { - if let Some(p) = self.parts_mut().get_mut_by_raw_id(id) { - p.set_hidden(!p.is_hidden()); - } - self.mark_scene_dirty(); - self.area.redraw(cx); - } - - /// Hide the selected parts only (command palette + Ctrl+K). - pub(crate) fn hide_selected(&mut self, cx: &mut Cx) { - if self.selection.is_empty() { - return; - } - let selection = self.selection.clone(); - for p in self.parts_mut().iter_mut() { - if selection.contains(&p.id.raw()) { - p.set_hidden(true); - } - } - self.selection.clear(); - self.mark_selection_dirty(); - self.mark_scene_dirty(); - self.area.redraw(cx); - } - - /// Alt+H toggle: if anything is hidden, show everything; otherwise hide - /// the current selection (mirroring Cmd+K). Gives a single hide/unhide-all - /// chord. - pub(crate) fn toggle_all_visibility(&mut self, cx: &mut Cx) { - if self.parts().iter().any(|p| p.is_hidden()) { - self.show_all(cx); - } else { - self.hide_selected(cx); - } - } - - /// Select a single part by id, clearing the rest (used by the outliner). - pub(crate) fn outliner_select(&mut self, cx: &mut Cx, id: u64) { - self.selection = vec![id]; - self.mark_selection_dirty(); - self.area.redraw(cx); - } - - /// Snapshot of the scene for the outliner: owned per-part rows so the - /// caller is not tied to a borrow guard. Row = `(id, name, kind, - /// hidden, selected)`. - pub(crate) fn outliner_rows(&self) -> Vec<(u64, String, PartKind, bool, bool)> { - let doc = self.document(); - let parts = CadViewport::read_parts(&doc); - parts - .iter() - .map(|p| { - ( - p.id.raw(), - p.name.clone(), - p.part_kind(), - p.is_hidden(), - self.selection.contains(&p.id.raw()), - ) - }) - .collect() - } - - /// Build the element info card for the first selected part (kind, name, - /// id, position, size, triangle count). Returns `None` when nothing is - /// selected. Powers the outliner reveal / info readout. - pub(crate) fn selected_info_card(&self) -> Option { - let id = *self.selection.first()?; - let doc = self.document(); - let parts = CadViewport::read_parts(&doc); - let part = parts.iter().find(|p| p.id.raw() == id)?; - let mesh_cache = self.scene_cache.mesh_cache(); - let tris = mesh_cache.get_or_build(part).triangles.len(); - Some(super::properties::info_card_text(part, tris)) - } - - /// Reconstruct the active CPU-clip section plane, or `None` when the - /// section cut is off. `axis` 0=X, 1=Y, 2=Z; clips keep `coord <= offset`. - pub(crate) fn section_plane(&self) -> Option { - if !self.section_active { - return None; - } - Some(match self.section_axis { - 0 => super::section::SectionPlane::axis_x(self.section_offset), - 1 => super::section::SectionPlane::axis_y(self.section_offset), - _ => super::section::SectionPlane::axis_z(self.section_offset), - }) - } - - /// Whether a section cut is currently applied. - pub(crate) fn section_is_active(&self) -> bool { - self.section_active - } - - /// Set (or clear) the section cut. `axis` 0=X, 1=Y, 2=Z, `offset` in - /// world units along that axis. Setting `active = false` clears the cut. - pub(crate) fn set_section(&mut self, axis: u8, offset: f64, active: bool, cx: &mut Cx) { - self.section_axis = axis; - self.section_offset = offset; - self.section_active = active; - self.mark_scene_dirty(); - self.area.redraw(cx); - } - - /// Explode displacement for the part at document row `id_idx` (0-based). - /// `(0,0,0)` when explode is off or for element 0. - pub(crate) fn explode_displacement(&self, id_idx: usize) -> (f64, f64, f64) { - super::explode::displacement_for( - id_idx, - &super::explode::ExplodeState { amount: self.explode_amount }, - ) - } - - /// Current explode amount (world units per index step); `0.0` = off. - pub(crate) fn explode_amount(&self) -> f64 { - self.explode_amount - } - - /// Set the explode amount (world units per index step); `0.0` disables. - pub(crate) fn set_explode(&mut self, amount: f64, cx: &mut Cx) { - self.explode_amount = amount; - self.mark_scene_dirty(); - self.area.redraw(cx); - } - - /// Whether the sun study is active. - pub(crate) fn sun_is_active(&self) -> bool { - self.sun_active - } - - /// Unit vector toward the sun for the current hour, or `None` when the - /// sun study is off. Drives the key-light direction (`u_light_dir`). - pub(crate) fn sun_direction(&self) -> Option { - if !self.sun_active { - return None; - } - let s = super::sun::SunSettings { - latitude: 40.7, - longitude: -74.0, - date: (6, 21), - hour: self.sun_hour, - }; - let (az, el) = super::sun::solar_position(&s); - let (x, y, z) = super::sun::direction(az, el); - Some(makepad_widgets::Vec3f { - x, - y, - z: z.max(0.0), // never push the key light below the horizon - }) - } - - /// Toggle the sun study on/off and set the hour (0..24). - pub(crate) fn set_sun(&mut self, active: bool, hour: f64, cx: &mut Cx) { - self.sun_active = active; - self.sun_hour = hour.clamp(0.0, 24.0); - self.mark_scene_dirty(); - self.area.redraw(cx); - } - - /// Current sun-study hour (0..24). - pub(crate) fn sun_hour(&self) -> f64 { - self.sun_hour - } - - /// Whether X-ray silhouette mode is active. - pub(crate) fn xray_is_active(&self) -> bool { - self.xray - } - - /// Toggle X-ray silhouette mode. Renders the whole mesh as a flat blue - /// silhouette tint so interior geometry reads through. - pub(crate) fn set_xray(&mut self, on: bool, cx: &mut Cx) { - self.xray = on; - self.draw_mesh.xray = if on { 1.0 } else { 0.0 }; - self.area.redraw(cx); - } - /// Apply mesh subdivision to selected parts: each triangle is split /// into 4 sub-triangles, increasing mesh resolution. pub(crate) fn subdivide_selected(&mut self, cx: &mut Cx) { @@ -2158,12 +1848,6 @@ impl CadViewport { } let doc = self.document(); for (i, part) in CadViewport::read_parts(&doc).iter().enumerate() { - // Skip script-derived parts: they were decomposed out of the - // evaluated script, so re-serialising them would fight the - // handwritten source and re-enter the eval loop. - if is_script_bred(&part.name) { - continue; - } let name = format!("part{}", i); let kind = part.part_kind(); let ctor = match kind { @@ -2627,7 +2311,6 @@ impl CadViewport { self.touch_started_on_part = true; // Select the part and begin a drag from raw touch. self.selection = vec![id]; - self.mark_selection_dirty(); self.part_dragging = true; self.drag_last = touch.abs; self.drag_start_pos.clear(); @@ -2744,7 +2427,6 @@ impl CadViewport { } } - pub(crate) fn set_tool(&mut self, cx: &mut Cx, tool: CadTool) { self.cancel_drawing(); self.tool = tool; @@ -2798,9 +2480,6 @@ impl CadViewport { let mut best: Option<(f64, DVec2)> = None; let doc = self.document(); for part in CadViewport::read_parts(&doc).iter() { - if part.is_hidden() { - continue; - } let corners = [ DVec2 { x: part.pos().x as f64 - part.size().x as f64 * 0.5, @@ -2837,9 +2516,6 @@ impl CadViewport { let mut best: Option<(f64, DVec2)> = None; let doc = self.document(); for part in CadViewport::read_parts(&doc).iter() { - if part.is_hidden() { - continue; - } let hw = part.size().x as f64 * 0.5; let hd = part.size().z as f64 * 0.5; let px = part.pos().x as f64; @@ -2868,9 +2544,6 @@ impl CadViewport { let mut best: Option<(f64, DVec2)> = None; let doc = self.document(); for part in CadViewport::read_parts(&doc).iter() { - if part.is_hidden() { - continue; - } let cx = part.pos().x as f64; let cz = part.pos().z as f64; let center = DVec2 { x: cx, y: cz }; @@ -2890,9 +2563,6 @@ impl CadViewport { let mut best: Option<(f64, DVec2)> = None; let doc = self.document(); for part in CadViewport::read_parts(&doc).iter() { - if part.is_hidden() { - continue; - } if let Some(verts) = &part.polygon_verts() { for v in verts.iter() { let p = DVec2 { x: v.x, y: v.y }; @@ -2915,9 +2585,6 @@ impl CadViewport { let mut best: Option<(f64, DVec2)> = None; let doc = self.document(); for part in CadViewport::read_parts(&doc).iter() { - if part.is_hidden() { - continue; - } let corners = [ DVec2 { x: part.pos().x as f64 - part.size().x as f64 * 0.5, @@ -2977,9 +2644,6 @@ impl CadViewport { let mut best: Option<(f64, DVec2)> = None; let doc = self.document(); for part in CadViewport::read_parts(&doc).iter() { - if part.is_hidden() { - continue; - } let corners = [ DVec2 { x: part.pos().x as f64 - part.size().x as f64 * 0.5, @@ -3024,9 +2688,6 @@ impl CadViewport { let mut best: Option<(f64, DVec2)> = None; let doc = self.document(); for part in CadViewport::read_parts(&doc).iter() { - if part.is_hidden() { - continue; - } let corners = [ DVec2 { x: part.pos().x as f64 - part.size().x as f64 * 0.5, @@ -3078,179 +2739,6 @@ impl CadViewport { .unwrap_or_else(|| self.snap_to_grid(world)) } - /// BVH-accelerated 3D snap using actual mesh geometry. - /// - /// Falls back to the existing 2D `snap_point` when in TwoD mode. - /// In ThreeD mode, uses the BVH to find vertex/edge/face snap - /// candidates on actual triangles (not AABB bounding boxes). - pub(crate) fn snap_point_3d(&self, world: DVec2) -> DVec2 { - if !self.snap.snap_enabled { - return world; - } - match self.view_mode { - ViewMode::TwoD => self.snap_point(world), - ViewMode::ThreeD => { - let bvh_borrow = self.pick_bvh.0.borrow(); - let Some((_, ref bvh)) = *bvh_borrow else { - return self.snap_point(world); - }; - - let project = |p: DVec3| -> DVec2 { - let clip = mat4_mul_vec4( - &self.last_proj, - mat4_mul_vec4(&self.last_view, [p.x as f32, p.y as f32, p.z as f32, 1.0]), - ); - let ndc_x = clip[0] / clip[3]; - let ndc_y = clip[1] / clip[3]; - DVec2 { - x: (ndc_x as f64 * 0.5 + 0.5) * self.last_rect.size.x, - y: (-ndc_y as f64 * 0.5 + 0.5) * self.last_rect.size.y, - } - }; - - let ppw = super::snap::pixels_per_world_perspective( - self.camera.distance, - self.camera.fov_y, - self.last_rect.size.y as f32, - ) as f64; - - let opts = super::snap::SnapOptions { - vertex: self.snap.snap_node, - edge_midpoint: self.snap.snap_midpoint, - edge: self.snap.snap_nearest, - face: true, - ground: true, - radius_px: 20.0, - }; - - let doc = self.document(); - let mesh_cache = self.scene_cache.mesh_cache(); - let lookup = |node_id: u64| -> Option<(Vec<[f64; 3]>, Vec<[u32; 3]>, Mat4f)> { - let parts = CadViewport::read_parts(&doc); - for part in parts.iter() { - if part.id.raw() == node_id { - let mesh = mesh_cache.get_or_build(part); - let model = part_model_matrix(part); - let verts: Vec<[f64; 3]> = mesh.vertices.iter().map(|v| [v.x, v.y, v.z]).collect(); - let tris: Vec<[u32; 3]> = mesh.triangles.clone(); - return Some((verts, tris, model)); - } - } - None - }; - - let screen_to_ray = |pos: DVec2| -> Option<(DVec3, DVec3)> { - self.screen_to_ray_3d(pos) - }; - - if let Some(hit) = bvh.snap( - world, - &opts, - screen_to_ray, - &project, - lookup, - ppw, - ) { - DVec2 { x: hit.point.x, y: hit.point.z } - } else { - self.snap_point(world) - } - } - } - } - - /// Full 3D world point for the Measure tool at a screen position. - /// - /// Runs the BVH snap to recover a real vertex/edge/face point (not the - /// flattened XZ projection used by the 2D-dual `snap_point_3d`). Falls - /// back to the ground plane (z=0) where nothing solid is near the ray. - pub(crate) fn measure_point_3d(&self, screen: DVec2) -> Option { - match self.view_mode { - ViewMode::TwoD => { - let world = self.screen_to_view_2d(screen); - Some(DVec3 { x: world.x, y: world.y, z: 0.0 }) - } - ViewMode::ThreeD => { - if !self.snap.snap_enabled { - return None; - } - let bvh_borrow = self.pick_bvh.0.borrow(); - let Some((_, ref bvh)) = *bvh_borrow else { - return None; - }; - - let project = |p: DVec3| -> DVec2 { - let clip = mat4_mul_vec4( - &self.last_proj, - mat4_mul_vec4(&self.last_view, [p.x as f32, p.y as f32, p.z as f32, 1.0]), - ); - let ndc_x = clip[0] / clip[3]; - let ndc_y = clip[1] / clip[3]; - DVec2 { - x: (ndc_x as f64 * 0.5 + 0.5) * self.last_rect.size.x, - y: (-ndc_y as f64 * 0.5 + 0.5) * self.last_rect.size.y, - } - }; - - let ppw = super::snap::pixels_per_world_perspective( - self.camera.distance, - self.camera.fov_y, - self.last_rect.size.y as f32, - ) as f64; - - let opts = super::snap::SnapOptions { - vertex: self.snap.snap_node, - edge_midpoint: self.snap.snap_midpoint, - edge: self.snap.snap_nearest, - face: true, - ground: true, - radius_px: 20.0, - }; - - let doc = self.document(); - let mesh_cache = self.scene_cache.mesh_cache(); - let lookup = |node_id: u64| -> Option<(Vec<[f64; 3]>, Vec<[u32; 3]>, Mat4f)> { - let parts = CadViewport::read_parts(&doc); - for part in parts.iter() { - if part.id.raw() == node_id { - let mesh = mesh_cache.get_or_build(part); - let model = part_model_matrix(part); - let verts: Vec<[f64; 3]> = - mesh.vertices.iter().map(|v| [v.x, v.y, v.z]).collect(); - let tris: Vec<[u32; 3]> = mesh.triangles.clone(); - return Some((verts, tris, model)); - } - } - None - }; - - let screen_to_ray = |pos: DVec2| -> Option<(DVec3, DVec3)> { - self.screen_to_ray_3d(pos) - }; - - if let Some(hit) = bvh.snap( - screen, - &opts, - screen_to_ray, - &project, - lookup, - ppw, - ) { - Some(hit.point) - } else { - // Fall back to the ground plane where the ray crosses z=0. - let (origin, dir) = self.screen_to_ray_3d(screen)?; - if dir.z.abs() < 1e-9 { - None - } else { - let t = -origin.z / dir.z; - Some(origin + dir * t) - } - } - } - } - } - pub(crate) fn apply_ortho(&self, start: DVec2, current: DVec2) -> DVec2 { // If the work plane is rotated, operate in unrotated space so ortho/polar axis locks // align with the rotated grid axes rather than world axes. @@ -3657,7 +3145,6 @@ impl CadViewport { } } self.selection = vec![id]; - self.mark_selection_dirty(); // A tool finished and added parts (wall/circle/rect/area/ // column/beam). Pure addition: no existing mesh is affected. self.mark_scene_dirty(); @@ -3825,7 +3312,6 @@ impl CadViewport { prev = next; } self.selection = ids; - self.mark_selection_dirty(); // Pure addition (N walls from a path): no existing mesh affected. self.mark_scene_dirty(); self.script_dirty = true; @@ -3940,27 +3426,6 @@ impl CadViewport { self.isolate_selected(cx); true } - // Cycle the Measure tool's mode: Distance → Angle → Area. - // First press selects the Measure tool; further presses - // cycle the measurement kind and clear the in-progress draft. - KeyM => { - if self.tool != CadTool::Measure { - self.set_tool(cx, CadTool::Measure); - { - let mut m = self.measure.0.borrow_mut(); - m.kind = 0; - } - } else { - { - let mut m = self.measure.0.borrow_mut(); - m.kind = (m.kind + 1) % 3; - m.clear_points(); - m.done = false; - } - } - self.area.redraw(cx); - true - } // Subdivide selected mesh: each triangle → 4 sub-triangles KeyS if !self.drawing.is_drawing && !self.selection.is_empty() => { self.subdivide_selected(cx); @@ -3977,11 +3442,6 @@ impl CadViewport { self.cancel_drawing(); self.area.redraw(cx); true - } else if self.measure.0.borrow().len() > 0 || self.measure.0.borrow().done { - self.measure.0.borrow_mut().clear_points(); - self.measure.0.borrow_mut().done = false; - self.area.redraw(cx); - true } else if !self.selection.is_empty() { self.deselect_all(cx); true @@ -4012,22 +3472,6 @@ impl CadViewport { self.finish_drawing(cx); true } - // Measure: Enter closes the Area loop and commits it. - ReturnKey - if self.tool == CadTool::Measure - && self.measure.0.borrow().kind == 2 - && self.measure.0.borrow().len() >= 3 => - { - let kind = MeasureKind::Area; - let points = { self.measure.0.borrow().points() }; - if let Some(meas) = super::measure::commit(kind, &points, 2) { - self.measure.0.borrow_mut().completed.push(meas.label); - } - self.measure.0.borrow_mut().clear_points(); - self.measure.0.borrow_mut().done = true; - self.area.redraw(cx); - true - } Shift => true, // already tracked KeyF => { self.zoom_to_fit(cx); @@ -4271,41 +3715,6 @@ impl CadViewport { self.area.redraw(cx); true } - // Toggle orthographic/perspective projection - F5 if !self.drawing.is_drawing => { - self.toggle_ortho(cx); - true - } - // Preset views: F1=Front, F2=Back, F3=Left, F4=Right, F6=Top, F7=Bottom, F8=Isometric - // (only when not drawing and in 3D mode) - Key1 if !self.drawing.is_drawing && matches!(self.view_mode, ViewMode::ThreeD) && ke.modifiers.alt => { - self.set_preset_view(cx, camera_orbit::PresetView::Front); - true - } - Key2 if !self.drawing.is_drawing && matches!(self.view_mode, ViewMode::ThreeD) && ke.modifiers.alt => { - self.set_preset_view(cx, camera_orbit::PresetView::Back); - true - } - Key3 if !self.drawing.is_drawing && matches!(self.view_mode, ViewMode::ThreeD) && ke.modifiers.alt => { - self.set_preset_view(cx, camera_orbit::PresetView::Left); - true - } - Key4 if !self.drawing.is_drawing && matches!(self.view_mode, ViewMode::ThreeD) && ke.modifiers.alt => { - self.set_preset_view(cx, camera_orbit::PresetView::Right); - true - } - Key6 if !self.drawing.is_drawing && matches!(self.view_mode, ViewMode::ThreeD) && ke.modifiers.alt => { - self.set_preset_view(cx, camera_orbit::PresetView::Top); - true - } - Key7 if !self.drawing.is_drawing && matches!(self.view_mode, ViewMode::ThreeD) && ke.modifiers.alt => { - self.set_preset_view(cx, camera_orbit::PresetView::Bottom); - true - } - Key8 if !self.drawing.is_drawing && matches!(self.view_mode, ViewMode::ThreeD) && ke.modifiers.alt => { - self.set_preset_view(cx, camera_orbit::PresetView::Isometric); - true - } _ => false, } } @@ -4500,8 +3909,6 @@ impl Widget for CadViewport { self.draw_axis_labels_3d(cx); self.draw_ref_plane_3d(cx); self.draw_clip_planes_3d(cx); - self.draw_section_plane_3d(cx); - self.draw_sun_compass(cx); self.draw_construction_3d(cx); self.draw_view_cube(cx); self.draw_selection_info(cx); @@ -4867,10 +4274,6 @@ pub(crate) struct CadRebuildRequest { pub(crate) enum CadRebuildPayload { Mesh { mesh_data: CadMeshData, - /// Connected components of the script solid, decomposed on the - /// worker thread in model space. Empty when the script produced - /// nothing (or a single mesh with no identity to split). - components: Vec, saved: bool, save_error: Option, }, @@ -4945,7 +4348,6 @@ pub(crate) fn cad_rebuild_worker_loop( }; CadRebuildPayload::Mesh { mesh_data: cad_mesh_data_from_solid(&solid), - components: components_from_solid(&solid), saved: request.save_output && save_error.is_none(), save_error, } @@ -5093,29 +4495,6 @@ pub(super) fn part_model_matrix_cadnode(node: &CadNode) -> Mat4f { mat4_mul(&translate_mat(t.translation), &rzyx) } -/// Apply the explode displacement (if any) to a part's model matrix, so the -/// pick path and the draw path agree on where an exploded part sits. `amount` -/// is the viewport's current explode amount (0 = off); `id_idx` is the part's -/// 0-based document row index. -pub(super) fn apply_explode(model: Mat4f, id_idx: usize, amount: f64) -> Mat4f { - if amount <= 0.0 { - return model; - } - use super::math::{mat4_mul, translate_mat}; - let (dx, dy, dz) = super::explode::displacement_for( - id_idx, - &super::explode::ExplodeState { amount }, - ); - mat4_mul( - &translate_mat(makepad_widgets::Vec3f { - x: dx as f32, - y: dy as f32, - z: dz as f32, - }), - &model, - ) -} - // =========================================================================== // Tests for the mesh producers and the viewport's plain-data helpers. // diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/viewport_input.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/viewport_input.rs index 5d7bd05..652a8fb 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/viewport_input.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/viewport_input.rs @@ -26,7 +26,6 @@ use super::*; use makepad_widgets::*; use super::math::DVec3; -use super::measure::MeasureKind; impl CadViewport { /// Raw pointer dispatch: mouse down/move/up, scroll, hover picking. @@ -225,11 +224,27 @@ impl CadViewport { cx.redraw_all(); return; } - CadTool::Measure => { - self.handle_measure_click(cx, e.abs); + CadTool::Measure if matches!(self.view_mode, ViewMode::TwoD) => { + if self.drawing.is_drawing { + // Second point: compute distance and show status + let _dist = ((self.drawing.current_world.x + - self.drawing.start_world.x) + .powi(2) + + (self.drawing.current_world.y - self.drawing.start_world.y) + .powi(2)) + .sqrt(); + self.cancel_drawing(); + } else { + let world = self.screen_to_view_2d(e.abs); + self.drawing.is_drawing = true; + self.drawing.tool = self.tool; + self.drawing.start_world = self.snap_point(world); + self.drawing.current_world = self.drawing.start_world; + } + cx.redraw_all(); return; } - CadTool::Select => { + CadTool::Select | CadTool::Measure => { self.hovered_part = None; if let Some(id) = self.pick_part(e.abs) { if self.shift_pressed { @@ -239,7 +254,6 @@ impl CadViewport { } else { self.selection.push(id); } - self.mark_selection_dirty(); } else { // Select part; if it belongs to a group, select all group members let gid = self @@ -258,7 +272,6 @@ impl CadViewport { } else { self.selection = vec![id]; } - self.mark_selection_dirty(); } self.part_dragging = true; self.drag_last = e.abs; @@ -346,9 +359,6 @@ impl CadViewport { if matches!(self.view_mode, ViewMode::TwoD) { let doc = self.document(); for part in CadViewport::read_parts(&doc).iter() { - if part.is_hidden() { - continue; - } let sp = self.view_to_screen_2d(DVec2 { x: part.pos().x as f64, y: part.pos().z as f64, @@ -386,9 +396,6 @@ impl CadViewport { } else { let doc = self.document(); for part in CadViewport::read_parts(&doc).iter() { - if part.is_hidden() { - continue; - } if let Some((sx, sy)) = self.project_point([ part.pos().x, part.pos().y, @@ -415,7 +422,6 @@ impl CadViewport { } } } - self.mark_selection_dirty(); } } self.drag_select_start = None; @@ -772,7 +778,6 @@ impl CadViewport { } else { self.selection = vec![id]; } - self.mark_selection_dirty(); self.part_dragging = true; self.drag_last = fe.abs; self.drag_start_pos.clear(); @@ -865,7 +870,6 @@ impl CadViewport { .map_or(false, |hit| self.selection.contains(&hit)) { self.selection.clear(); - self.mark_selection_dirty(); self.drag_start_pos.clear(); self.part_dragging = false; self.area.redraw(cx); @@ -907,72 +911,4 @@ impl CadViewport { _ => {} } } - - /// One click of the Measure tool. - /// - /// Distance and angle gather points in order; area gathers an - /// open-ended loop. Each committed measurement is appended to the - /// completed list. - fn handle_measure_click(&mut self, cx: &mut Cx, abs: DVec2) { - use super::measure::MeasureKind; - - // Get the world point for this click. - let Some(point) = self.measure_point_3d(abs) else { - return; - }; - - let (kind, should_commit) = { - let mut m = self.measure.0.borrow_mut(); - let kind = match m.kind { - 1 => MeasureKind::Angle, - 2 => MeasureKind::Area, - _ => MeasureKind::Distance, - }; - match kind { - MeasureKind::Distance => { - if m.len() == 0 { - m.push_point(point); - (kind, false) - } else if !m.done { - m.push_point(point); - (kind, true) - } else { - (kind, false) - } - } - MeasureKind::Angle => { - if m.len() < 2 { - m.push_point(point); - (kind, false) - } else if !m.done { - m.push_point(point); - (kind, true) - } else { - (kind, false) - } - } - MeasureKind::Area => { - if !m.done { - m.push_point(point); - } - (kind, false) - } - } - }; - - if should_commit { - self.commit_measurement(kind); - } - cx.redraw_all(); - } - - /// Finalize the active measure and store the committed label. - fn commit_measurement(&mut self, kind: MeasureKind) { - use super::measure::commit; let points = self.measure.0.borrow().points(); - if let Some(meas) = commit(kind, &points, 2) { - self.measure.0.borrow_mut().completed.push(meas.label); - } - self.measure.0.borrow_mut().clear_points(); - self.measure.0.borrow_mut().done = false; - } } diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/viewport_render.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/viewport_render.rs index 16327e3..55ee7b6 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/viewport_render.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/viewport_render.rs @@ -36,8 +36,6 @@ use std::collections::HashMap; use std::sync::Arc; use super::math::DVec3; - -use super::script_parts::is_script_bred; use super::viewport::{ensure_ground_geometry, ensure_lod_geometry, part_model_matrix_cadnode}; /// One part's 2D outline, queued for a colour batch. @@ -452,26 +450,14 @@ impl CadViewport { self.draw_ground.depth_clip = 0.0; self.draw_ground.display_mode = self.render_mode.shader_value(); self.draw_ground.draw(cx, ground_id); - // The merged script mesh is redundant on this frame once its - // components have been decomposed into `__script__` parts, - // which the part loop below also draws. Drawing both would - // rasterise the same geometry twice; skip the mesh then. - let doc = self.document(); - let has_script_parts = CadViewport::read_parts(&doc) - .iter() - .any(|p| is_script_bred(&p.name)); if let Some(geom) = &self.mesh_geometry { - if !has_script_parts { - self.draw_mesh.transform = Mat4f::identity(); - self.draw_mesh.color = self.color; - self.draw_mesh.depth_clip = 0.0; - self.draw_mesh.display_mode = self.render_mode.shader_value(); - if let Some(dir) = self.sun_direction() { - self.draw_mesh.light_dir = dir; - } - self.draw_mesh.draw(cx, geom.geometry_id()); - } + self.draw_mesh.transform = Mat4f::identity(); + self.draw_mesh.color = self.color; + self.draw_mesh.depth_clip = 0.0; + self.draw_mesh.display_mode = self.render_mode.shader_value(); + self.draw_mesh.draw(cx, geom.geometry_id()); } + let doc = self.document(); // Phase 1: cull against the camera frustum before submitting. // Every part used to issue its own draw call whether or not // any pixel of it could land on screen. The matrices come @@ -489,10 +475,7 @@ impl CadViewport { // per-instance values in both paths. let mut visible: Vec<(ShapeHash, (Mat4f, Vec4f))> = Vec::new(); let mut lod_visible: Vec<(Mat4f, Vec4f)> = Vec::new(); - for (part_idx, part) in CadViewport::read_parts(&doc).iter().enumerate() { - if part.is_hidden() { - continue; - } + for part in CadViewport::read_parts(&doc).iter() { // Phase 2: the key is the shape's content hash, so a hit // is correct by construction. Under the previous // `(id, ParamHash)` keying this had to filter out @@ -529,14 +512,6 @@ impl CadViewport { &model, ) }); -if let Some(plane) = self.section_plane() { - if !plane.kept( - (aabb.min[0], aabb.min[1], aabb.min[2]), - (aabb.max[0], aabb.max[1], aabb.max[2]), - ) { - continue; - } - } if !frustum.draw_part_3d(&aabb, is_sel || is_hov) { continue; } @@ -552,18 +527,6 @@ if let Some(plane) = self.section_plane() { } else { part.color }; -let mut model = part_model_matrix_cadnode(part); - if self.explode_amount > 0.0 { - let (dx, dy, dz) = self.explode_displacement(part_idx); - model = super::math::mat4_mul( - &super::math::translate_mat(Vec3f { - x: dx as f32, - y: dy as f32, - z: dz as f32, - }), - &model, - ); - } match super::lod::part_lod_3d( &aabb, &scene_state.view, @@ -747,9 +710,6 @@ let mut model = part_model_matrix_cadnode(part); let mut plain_points: Vec<(super::batching::ColorKey, PartPoint2D)> = Vec::new(); let mut decorated_points: Vec<(super::batching::ColorKey, PartPoint2D)> = Vec::new(); for part in CadViewport::read_parts(&doc).iter() { - if part.is_hidden() { - continue; - } let is_sel = self.selection.contains(&part.id.raw()); let is_hov = self.hovered_part.map_or(false, |h| h == part.id.raw()) && !is_sel; let decorated_part = is_sel || is_hov; @@ -1282,9 +1242,6 @@ let mut model = part_model_matrix_cadnode(part); pub(crate) fn draw_section_indicators(&mut self, cx: &mut Cx2d) { let doc = self.document(); for part in CadViewport::read_parts(&doc).iter() { - if part.is_hidden() { - continue; - } if part.kind_hint != Some(PartKind::Beam) { continue; } @@ -1729,6 +1686,7 @@ let mut model = part_model_matrix_cadnode(part); let world_right = self.pan_2d.x + half_w * super::render_budget::VIEW_MARGIN; let world_bot = self.pan_2d.y - half_h * super::render_budget::VIEW_MARGIN; let world_top = self.pan_2d.y + half_h * super::render_budget::VIEW_MARGIN; + // Adaptive grid spacing: target ~60px between grid lines let wpp = (half_h * 2.0) / rect.size.y.max(1.0); let target_px = 60.0; @@ -2761,123 +2719,6 @@ let mut model = part_model_matrix_cadnode(part); self.draw_vector.end(cx); } - /// Draw a translucent quad + normal tick for the live section cut, so the - /// cut plane is visible while the CPU clip drops parts on the far side. - pub(crate) fn draw_section_plane_3d(&mut self, cx: &mut Cx2d) { - let Some(plane) = self.section_plane() else { return }; - let offset = plane.offset as f32; - let ext = 12.0_f32; - self.draw_vector.begin(); - self.draw_vector.set_color(0.3, 0.7, 1.0, 0.28); - let quad: [[f32; 4]; 4] = match self.section_axis { - 0 => [ - [offset, -ext, -ext, 1.0], - [offset, -ext, ext, 1.0], - [offset, ext, ext, 1.0], - [offset, ext, -ext, 1.0], - ], - 1 => [ - [-ext, offset, -ext, 1.0], - [-ext, offset, ext, 1.0], - [ext, offset, ext, 1.0], - [ext, offset, -ext, 1.0], - ], - _ => [ - [-ext, -ext, offset, 1.0], - [-ext, ext, offset, 1.0], - [ext, ext, offset, 1.0], - [ext, -ext, offset, 1.0], - ], - }; - self.draw_projected_quad(&quad); - // Normal tick: a short line at the plane's centre pointing along +axis - // (the kept side for the constructors we use). - self.draw_vector.set_color(0.3, 0.7, 1.0, 0.9); - let (o, d) = match self.section_axis { - 0 => ( - [offset, 0.0, 0.0, 1.0], - [offset + 2.0, 0.0, 0.0, 1.0], - ), - 1 => ( - [0.0, offset, 0.0, 1.0], - [0.0, offset + 2.0, 0.0, 1.0], - ), - _ => ( - [0.0, 0.0, offset, 1.0], - [0.0, 0.0, offset + 2.0, 1.0], - ), - }; - if let (Some((sx1, sy1)), Some((sx2, sy2))) = - (self.project_point(o), self.project_point(d)) - { - self.draw_dashed_line(sx1 as f32, sy1 as f32, sx2 as f32, sy2 as f32); - } - self.draw_vector.end(cx); - } - - /// Sun-study compass: a ground disc with a tick pointing *away* from the - /// sun (the shadow direction) plus the sun elevation, drawn only while - /// the sun study is active. - pub(crate) fn draw_sun_compass(&mut self, cx: &mut Cx2d) { - if !self.sun_is_active() { - return; - } - let Some(dir) = self.sun_direction() else { return }; - let cp = self.compass_center(); - let r = 14.0_f32; - self.draw_vector.begin(); - // Ground disc. - self.draw_vector.set_color(0.35, 0.3, 0.55, 0.35); - let segs = 40; - let mut prev = None; - for i in 0..=segs { - let a = std::f64::consts::TAU * (i as f64) / (segs as f64); - let p = [ - cp[0] + (a.cos() * r as f64) as f32, - 0.0, - cp[2] + (a.sin() * r as f64) as f32, - 1.0, - ]; - if let Some((sx, sy)) = self.project_point(p) { - if let Some((px, py)) = prev { - self.draw_dashed_line(px, py, sx as f32, sy as f32); - } - prev = Some((sx as f32, sy as f32)); - } else { - prev = None; - } - } - // Shadow tick: opposite the sun direction, projected onto XZ. - let sh = [-dir.x, 0.0, -dir.z]; - let shm = (sh[0] * sh[0] + sh[2] * sh[2]).sqrt(); - if shm > 1e-4 { - let tip = [ - cp[0] + sh[0] / shm * r as f32 * 0.8, - 0.0, - cp[2] + sh[2] / shm * r as f32 * 0.8, - 1.0, - ]; - self.draw_vector.set_color(1.0, 0.85, 0.3, 0.95); - if let (Some((sx1, sy1)), Some((sx2, sy2))) = - (self.project_point([cp[0], 0.0, cp[2], 1.0]), self.project_point(tip)) - { - self.draw_dashed_line( - sx1 as f32, - sy1 as f32, - sx2 as f32, - sy2 as f32, - ); - } - } - self.draw_vector.end(cx); - let _ = dir; - } - - /// A world-space anchor near the model where the compass sits. - fn compass_center(&self) -> [f32; 3] { - [8.0, 0.0, 8.0] - } - pub(crate) fn draw_construction_3d(&mut self, cx: &mut Cx2d) { if !self.construction_visible { return; diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/workspace.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/workspace.rs index 3ffabdf..9847bd6 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/workspace.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cad/workspace.rs @@ -437,130 +437,6 @@ impl CadWorkspace { }); } - /// Scroll-delta stepper for the properties-panel numeric fields. - /// - /// A wheel/trackpad vertical scroll over one of the X/Y/Z/W/H/D/Rot - /// inputs steps that field's value using `drag_num::header_drag_math`. - /// Single-line numeric `TextInput`s deliberately do not consume vertical - /// scroll, so we catch it here before it reaches the live view. - fn handle_numeric_scroll_stepper(&mut self, cx: &mut Cx, event: &Event) { - if let Event::Scroll(e) = event { - if e.handled_y.get() { - return; - } - let abs = e.abs; - let delta = e.scroll.y; - if delta == 0.0 { - return; - } - const PPS: f64 = 40.0; - let fields = [ - ids!(pos_x_input), - ids!(pos_y_input), - ids!(pos_z_input), - ids!(size_w_input), - ids!(size_h_input), - ids!(size_d_input), - ids!(rot_x_input), - ids!(rot_y_input), - ids!(rot_z_input), - ]; - let steps = [0.1f64, 0.1, 0.1, 0.1, 0.1, 0.1, 1.0, 1.0, 1.0]; - for (i, field) in fields.iter().enumerate() { - let rect = self.view.text_input(cx, *field).area().rect(cx); - if !rect.contains(abs) { - continue; - } - let anchor = self - .view - .text_input(cx, *field) - .text() - .parse::() - .unwrap_or(0.0); - let new_v = super::drag_num::header_drag_math(anchor, delta, PPS, steps[i], false); - self.view - .text_input(cx, *field) - .set_text(cx, &Self::fmt_num(new_v)); - self.apply_numeric_field(cx, i, new_v as f32); - e.handled_y.set(true); - return; - } - } - } - - /// Apply a stepped numeric value to the selected part for a given field - /// index (`0..2` pos, `3..5` size, `6..8` rotation). - fn apply_numeric_field(&mut self, cx: &mut Cx, i: usize, v: f32) { - match i { - 0 => self.apply_field_to_selected_part(cx, v, |p, val| { - let mut t = p.pos(); - t.x = val; - p.set_pos(t); - }), - 1 => self.apply_field_to_selected_part(cx, v, |p, val| { - let mut t = p.pos(); - t.y = val; - p.set_pos(t); - }), - 2 => self.apply_field_to_selected_part(cx, v, |p, val| { - let mut t = p.pos(); - t.z = val; - p.set_pos(t); - }), - 3 => self.apply_field_to_selected_part(cx, v, |p, val| { - let mut t = p.size(); - t.x = val; - p.set_size(t); - }), - 4 => self.apply_field_to_selected_part(cx, v, |p, val| { - let mut t = p.size(); - t.y = val; - p.set_size(t); - }), - 5 => self.apply_field_to_selected_part(cx, v, |p, val| { - let mut t = p.size(); - t.z = val; - p.set_size(t); - }), - 6 => self.apply_field_to_selected_part(cx, v, |p, val| { - let mut t = p.rot(); - t.x = val; - p.set_rot(t); - }), - 7 => self.apply_field_to_selected_part(cx, v, |p, val| { - let mut t = p.rot(); - t.y = val; - p.set_rot(t); - }), - 8 => self.apply_field_to_selected_part(cx, v, |p, val| { - let mut t = p.rot(); - t.z = val; - p.set_rot(t); - }), - _ => {} - } - } - - /// Format a stepped value: drop trailing zeros but keep enough precision. - fn fmt_num(v: f64) -> String { - if (v - v.round()).abs() < 1e-9 { - format!("{v:.0}") - } else { - format!("{v:.2}") - } - } - - /// Toggle X-ray silhouette mode across all viewports (Alt+Z / X-Ray button). - fn toggle_xray(&mut self, cx: &mut Cx) { - let on = self - .view - .widget(cx, ids!(cad_viewport)) - .borrow::() - .map(|vp| !vp.xray_is_active()) - .unwrap_or(false); - self.apply_to_all_viewports(cx, |vp, cx| vp.set_xray(on, cx)); - } - pub(crate) fn apply_to_all_viewports( &mut self, cx: &mut Cx, @@ -659,7 +535,6 @@ impl CadWorkspace { // is turned back on. let _ = self.sync_parts_from_any_dirty_viewport(cx); self.sync_view_from_any_dirty_viewport(cx); - self.sync_selection_properties(cx); } self.view.redraw(cx); @@ -747,27 +622,6 @@ impl CadWorkspace { cx.redraw_all(); } - /// If any viewport changed its selection this frame, push the - /// properties readout to the status bar. Mirrors the camera/view - /// dirty-flag sync so selection-driven status stays in step. - fn sync_selection_properties(&mut self, cx: &mut Cx) { - let mut dirty = false; - for id in [ids!(cad_viewport), ids!(cad_viewport_2d), ids!(cad_viewport_3d)] { - if let Some(mut vp) = self.view.widget(cx, id).borrow_mut::() { - if vp.take_selection_dirty() { - dirty = true; - break; - } - } - } - if dirty { - self.refresh_selection_properties_status(cx); - if self.outliner_open { - self.refresh_outliner(cx); - } - } - } - /// Collect the script from any viewport that reported an edit and /// make the others redraw. /// @@ -1164,20 +1018,10 @@ impl CadWorkspace { match result.payload { CadRebuildPayload::Mesh { mesh_data, - components, saved, save_error, } => { let stats = self.set_mesh_on_all_viewports(cx, mesh_data); - if !components.is_empty() { - if let Some(mut vp) = self - .view - .widget(cx, ids!(cad_viewport)) - .borrow_mut::() - { - vp.replace_script_parts(cx, &components); - } - } self.apply_to_all_viewports(cx, |vp, cx| vp.zoom_to_fit(cx)); let sv = if let Some(e) = save_error { format!("; save failed: {e}") @@ -1670,437 +1514,6 @@ impl CadWorkspace { self.view.view(cx, path).set_visible(cx, !text.is_empty()); } - /// Push the current selection's properties readout into the status - /// bar. Called when a viewport reports its selection changed - /// (`take_selection_dirty`). Uses the first (primary) viewport's - /// selection; all viewports share one document. - fn refresh_selection_properties_status(&mut self, cx: &mut Cx) { - let summary = if let Some(vp) = self - .view - .widget(cx, ids!(cad_viewport)) - .borrow::() - { - vp.properties_summary() - } else { - String::new() - }; - let text = if summary.is_empty() { - crate::construction_frame::pages::workspace::cad::properties::no_selection_hint() - .to_string() - } else { - summary - }; - self.set_status_label(cx, ids!(status_label), &text); - } - - /// Handle the outliner panel toggle and its action buttons. - fn handle_outliner_actions(&mut self, cx: &mut Cx, actions: &Actions) { - if self.view.button(cx, ids!(outliner_toggle_btn)).clicked(actions) { - let open = !self.outliner_open; - self.outliner_open = open; - self.view.view(cx, ids!(outliner_panel)).set_visible(cx, open); - if open { - self.refresh_outliner(cx); - self.view - .label(cx, ids!(outliner_kind_btn)) - .set_text(cx, &self.outliner_kind_label()); - } - } - if self - .view - .text_input(cx, ids!(outliner_search_input)) - .changed(actions) - .is_some() - { - self.outliner_filter_query = self - .view - .text_input(cx, ids!(outliner_search_input)) - .text(); - self.refresh_outliner(cx); - } - if self.view.button(cx, ids!(outliner_kind_btn)).clicked(actions) { - self.outliner_kind_filter = self.cycle_outliner_kind(self.outliner_kind_filter); - self.view - .label(cx, ids!(outliner_kind_btn)) - .set_text(cx, &self.outliner_kind_label()); - self.refresh_outliner(cx); - } - if self.view.button(cx, ids!(outliner_close_btn)).clicked(actions) { - self.outliner_open = false; - self.view.view(cx, ids!(outliner_panel)).set_visible(cx, false); - } - if self.view.button(cx, ids!(outliner_show_all_btn)).clicked(actions) { - self.apply_to_all_viewports(cx, |vp, cx| vp.show_all(cx)); - self.refresh_outliner(cx); - } - if self.view.button(cx, ids!(outliner_hide_all_btn)).clicked(actions) { - self.apply_to_all_viewports(cx, |vp, cx| vp.hide_all(cx)); - self.refresh_outliner(cx); - } - if self.view.button(cx, ids!(outliner_isolate_btn)).clicked(actions) { - self.apply_to_all_viewports(cx, |vp, cx| vp.isolate_selected(cx)); - self.refresh_outliner(cx); - } - if self.view.button(cx, ids!(outliner_info_btn)).clicked(actions) { - // Reveal the info card for the first selected part in the - // outliner readout (kind, id, pos, size, tris). - let card = self - .view - .widget(cx, ids!(cad_viewport)) - .borrow::() - .and_then(|vp| vp.selected_info_card()); - self.view - .label(cx, ids!(outliner_text_label)) - .set_text(cx, &card.unwrap_or_else(|| "Select a part for its info".to_string())); - } - if self.view.button(cx, ids!(section_x_btn)).clicked(actions) { - self.apply_to_all_viewports(cx, |vp, cx| vp.set_section(0, 0.0, true, cx)); - } - if self.view.button(cx, ids!(section_y_btn)).clicked(actions) { - self.apply_to_all_viewports(cx, |vp, cx| vp.set_section(1, 0.0, true, cx)); - } - if self.view.button(cx, ids!(section_z_btn)).clicked(actions) { - self.apply_to_all_viewports(cx, |vp, cx| vp.set_section(2, 0.0, true, cx)); - } - if self.view.button(cx, ids!(section_clear_btn)).clicked(actions) { - self.apply_to_all_viewports(cx, |vp, cx| vp.set_section(0, 0.0, false, cx)); - } - if self.view.button(cx, ids!(explode_plus_btn)).clicked(actions) { - self.apply_to_all_viewports(cx, |vp, cx| { - let cur = vp.explode_amount(); - vp.set_explode((cur + 0.5).min(12.0), cx); - }); - } - if self.view.button(cx, ids!(explode_minus_btn)).clicked(actions) { - self.apply_to_all_viewports(cx, |vp, cx| { - let cur = vp.explode_amount(); - vp.set_explode((cur - 0.5).max(0.0), cx); - }); - } - if self.view.button(cx, ids!(sun_toggle_btn)).clicked(actions) { - self.apply_to_all_viewports(cx, |vp, cx| { - vp.set_sun(!vp.sun_is_active(), vp.sun_hour(), cx); - }); - } - if self.view.button(cx, ids!(sun_hour_down_btn)).clicked(actions) { - self.apply_to_all_viewports(cx, |vp, cx| { - vp.set_sun(true, (vp.sun_hour() - 1.0).max(0.0), cx); - }); - } - if self.view.button(cx, ids!(sun_hour_up_btn)).clicked(actions) { - self.apply_to_all_viewports(cx, |vp, cx| { - vp.set_sun(true, (vp.sun_hour() + 1.0).min(24.0), cx); - }); - } - if self.view.button(cx, ids!(xray_btn)).clicked(actions) { - self.toggle_xray(cx); - } - if self.view.button(cx, ids!(outliner_toggle_vis_btn)).clicked(actions) { - // Toggle visibility of the first selected part (the active row). - let id = self - .view - .widget(cx, ids!(cad_viewport)) - .borrow::() - .map(|vp| vp.selection_ids().first().copied()); - if let Some(Some(id)) = id { - self.apply_to_all_viewports(cx, |vp, cx| vp.toggle_part_visibility(cx, id)); - } - self.refresh_outliner(cx); - } - if self.view.button(cx, ids!(outliner_sel_prev_btn)).clicked(actions) { - self.outliner_step_selection(cx, -1); - } - if self.view.button(cx, ids!(outliner_sel_next_btn)).clicked(actions) { - self.outliner_step_selection(cx, 1); - } - } - - /// Step the outliner selection to the next/previous part (by document - /// order) and re-render the panel. - fn outliner_step_selection(&mut self, cx: &mut Cx, dir: i64) { - let Some(rows) = self - .view - .widget(cx, ids!(cad_viewport)) - .borrow::() - .map(|vp| vp.outliner_rows()) - else { - return; - }; - if rows.is_empty() { - return; - } - let cur = self - .view - .widget(cx, ids!(cad_viewport)) - .borrow::() - .map(|vp| vp.selection_ids().first().copied()) - .flatten(); - let cur_idx = cur.and_then(|c| rows.iter().position(|(id, ..)| *id == c)); - let next_idx = match cur_idx { - Some(i) => (i as i64 + dir).rem_euclid(rows.len() as i64) as usize, - None if dir < 0 => rows.len() - 1, - None => 0, - }; - let id = rows[next_idx].0; - self.apply_to_all_viewports(cx, |vp, cx| vp.outliner_select(cx, id)); - self.refresh_outliner(cx); - } - - /// Human-readable label for the current outliner kind funnel. - fn outliner_kind_label(&self) -> String { - match self.outliner_kind_filter { - None => "Kind".to_string(), - Some(k) => format!("{} ✓", k.label()), - } - } - - /// Cycle the kind funnel through None -> all variants -> back to None. - fn cycle_outliner_kind(&self, current: Option) -> Option { - use super::cad_scene::PartKind::*; - const ORDER: [PartKind; 13] = [ - Cube, Cylinder, Sphere, Rect2D, Circle2D, Arc, Polygon2D, Wall, Slab, Door, Window, - Column, Beam, - ]; - match current { - None => Some(ORDER[0]), - Some(k) => { - if let Some(pos) = ORDER.iter().position(|&x| x == k) { - ORDER.get(pos + 1).copied() - } else { - None - } - } - } - } - - /// Rebuild the outliner text label from the primary viewport's parts, - /// applying the live search query and kind funnel before rendering. - fn refresh_outliner(&mut self, cx: &mut Cx) { - let rows = if let Some(vp) = self - .view - .widget(cx, ids!(cad_viewport)) - .borrow::() - { - vp.outliner_rows() - } else { - Vec::new() - }; - use super::outliner::{filter_rows, filter_rows_by_kind}; - let mut rows = filter_rows_by_kind(&rows, self.outliner_kind_filter); - rows = filter_rows(&rows, &self.outliner_filter_query); - let total = if let Some(vp) = self - .view - .widget(cx, ids!(cad_viewport)) - .borrow::() - { - vp.outliner_rows().len() - } else { - 0 - }; - let filtered_count = rows.len(); - let text = crate::construction_frame::pages::workspace::cad::outliner::outliner_text_rows( - &rows, - ); - self.view - .label(cx, ids!(outliner_text_label)) - .set_text(cx, &text); - self.view - .label(cx, ids!(outliner_count_label)) - .set_text(cx, &format!("{filtered_count}/{total}")); - } - - /// Show/hide the command palette overlay and (re)initialise its state. - fn toggle_palette(&mut self, cx: &mut Cx, open: bool) { - self.palette_open = open; - self.view.view(cx, ids!(palette_panel)).set_visible(cx, open); - if open { - self.palette_query.clear(); - self.palette_cursor = 0; - self.palette_hits = super::command_palette::filter(""); - self.view.text_input(cx, ids!(palette_input)).set_text(cx, ""); - self.refresh_palette(cx); - } - } - - /// Toggle the F1 keymap help overlay, rendering the keymap table fresh from - /// the single source of truth (`keymap::render_groups`) each time it opens. - fn toggle_keymap(&mut self, cx: &mut Cx, open: bool) { - self.keymap_open = open; - self.view.view(cx, ids!(keymap_panel)).set_visible(cx, open); - if open { - let text = super::keymap::render_groups(); - self.view - .label(cx, ids!(keymap_text_label)) - .set_text(cx, &text); - } - } - - /// Re-render the palette result list from `palette_hits`/`palette_cursor`. - fn refresh_palette(&mut self, cx: &mut Cx) { - if self.palette_hits.is_empty() { - self.view - .label(cx, ids!(palette_text_label)) - .set_text(cx, "No command matches"); - return; - } - let mut out = String::new(); - for (i, cmd) in self.palette_hits.iter().enumerate() { - let mark = if i == self.palette_cursor { "►" } else { " " }; - out.push_str(&format!("{mark} {:<22} {}\n", cmd.label(), cmd.shortcut())); - } - self.view - .label(cx, ids!(palette_text_label)) - .set_text(cx, &out); - } - - /// Execute a palette command by dispatching to the same handlers our - /// toolbar buttons and hotkeys use, then close the palette. - fn run_command(&mut self, cx: &mut Cx, cmd: super::command_palette::CadCommand) { - use super::command_palette::CadCommand as C; - use super::camera_orbit::PresetView; - use super::viewport::CadRenderMode; - match cmd { - C::FrameAll => self.apply_to_all_viewports(cx, |vp, cx| vp.zoom_to_fit(cx)), - C::FrameSelected => self.apply_to_all_viewports(cx, |vp, cx| vp.frame_selection(cx)), - C::CycleShading => { - let next = match self.render_mode { - CadRenderMode::Wireframe => CadRenderMode::HiddenLine, - CadRenderMode::HiddenLine => CadRenderMode::Shaded, - CadRenderMode::Shaded => CadRenderMode::ConsistentColors, - CadRenderMode::ConsistentColors => CadRenderMode::Realistic, - CadRenderMode::Realistic => CadRenderMode::RayTrace, - CadRenderMode::RayTrace => CadRenderMode::Wireframe, - }; - self.set_render_mode(cx, next); - } - C::ToggleOrtho => self.apply_to_all_viewports(cx, |vp, cx| vp.toggle_ortho(cx)), - C::ViewFront => self.apply_to_all_viewports(cx, |vp, cx| { - vp.set_preset_view(cx, PresetView::Front) - }), - C::ViewRight => self.apply_to_all_viewports(cx, |vp, cx| { - vp.set_preset_view(cx, PresetView::Right) - }), - C::ViewTop => self.apply_to_all_viewports(cx, |vp, cx| { - vp.set_preset_view(cx, PresetView::Top) - }), - C::ViewIsometric => self.apply_to_all_viewports(cx, |vp, cx| { - vp.set_preset_view(cx, PresetView::Isometric) - }), - C::HideSelected => self.apply_to_all_viewports(cx, |vp, cx| vp.hide_selected(cx)), - C::IsolateSelected => self.apply_to_all_viewports(cx, |vp, cx| vp.isolate_selected(cx)), - C::ShowAll => self.apply_to_all_viewports(cx, |vp, cx| vp.show_all(cx)), - C::ToggleOutliner => { - let open = !self.outliner_open; - self.outliner_open = open; - self.view.view(cx, ids!(outliner_panel)).set_visible(cx, open); - if open { - self.refresh_outliner(cx); - } - } - C::Undo => self.apply_to_all_viewports(cx, |vp, cx| { - vp.undo(cx); - }), - C::Redo => self.apply_to_all_viewports(cx, |vp, cx| { - vp.redo(cx); - }), - C::RenderImage => self.render_image(cx), - } - self.toggle_palette(cx, false); - self.view.redraw(cx); - } - - /// High-res render command (F12): build render settings, produce an RGB - /// framebuffer for the current scene and write it as a PNG via the shared - /// tested encoder. Reads the first viewport's dimensions so the output - /// matches the aspect ratio being edited. - fn render_image(&mut self, cx: &mut Cx) { - use super::render_export::{RenderSettings, write_render_png}; - let settings = RenderSettings::default().sanitize(); - let w = settings.width as usize; - let h = settings.height as usize; - // There is no GPU read-back in this build, so produce a representative - // shaded framebuffer: a vertical "sky-to-ground" gradient that keeps - // the PNG non-empty and sized exactly to the settings. - let mut rgb = vec![0u8; settings.pixel_count() as usize * 3]; - let mut i = 0usize; - for y in 0..h { - let t = y as f64 / h as f64; - let (r, g, b) = ( - (0xE8u8 as f64 - t * 48.0) as u8, - (0x74u8 as f64 - t * 40.0) as u8, - (0x2Eu8 as f64 - t * 24.0) as u8, - ); - for _ in 0..w { - rgb[i] = r; - rgb[i + 1] = g; - rgb[i + 2] = b; - i += 3; - } - } - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0); - let out = crate::dir::app_data_dir() - .join("renders") - .join(format!("render_{stamp}")); - match write_render_png(&settings, &rgb, &out.to_string_lossy()) { - Ok(path) => makepad_widgets::log!("[CAD_RENDER] saved {path}"), - Err(e) => error!("[CAD_RENDER] render failed: {e}"), - } - self.view.redraw(cx); - } - - /// Handle the palette toggle button, its text input, and its result buttons. - fn handle_palette_actions(&mut self, cx: &mut Cx, actions: &Actions) { - if self.view.button(cx, ids!(palette_toggle_btn)).clicked(actions) { - self.toggle_palette(cx, !self.palette_open); - return; - } - if !self.palette_open { - return; - } - if self.view.button(cx, ids!(palette_close_btn)).clicked(actions) { - self.toggle_palette(cx, false); - return; - } - if self.view.button(cx, ids!(keymap_close_btn)).clicked(actions) { - self.toggle_keymap(cx, false); - return; - } - let input = self.view.text_input(cx, ids!(palette_input)); - if let Some(text) = input.changed(actions) { - self.palette_query = text; - self.palette_cursor = 0; - self.palette_hits = super::command_palette::filter(&self.palette_query); - self.refresh_palette(cx); - } - if input.returned(actions).is_some() { - if let Some(cmd) = self.palette_hits.get(self.palette_cursor).copied() { - self.run_command(cx, cmd); - } - return; - } - if self.view.button(cx, ids!(palette_run_btn)).clicked(actions) { - if let Some(cmd) = self.palette_hits.get(self.palette_cursor).copied() { - self.run_command(cx, cmd); - } - return; - } - if self.view.button(cx, ids!(palette_prev_btn)).clicked(actions) { - if !self.palette_hits.is_empty() { - self.palette_cursor = - (self.palette_cursor + self.palette_hits.len() - 1) % self.palette_hits.len(); - self.refresh_palette(cx); - } - } - if self.view.button(cx, ids!(palette_next_btn)).clicked(actions) { - if !self.palette_hits.is_empty() { - self.palette_cursor = (self.palette_cursor + 1) % self.palette_hits.len(); - self.refresh_palette(cx); - } - } - } pub(super) fn send_ai_prompt(&mut self, cx: &mut Cx) { if self.current_prompt.is_some() { return; @@ -2918,13 +2331,6 @@ impl CadWorkspace { return; } - // Return to the project dashboard from the editor. - if self.view.button(cx, ids!(back_to_dash_btn)).clicked(actions) { - self.show_dashboard = true; - self.view.redraw(cx); - return; - } - // The fold/page controls inside the draggable sheet header are handled directly from // pointer hits in `handle_direct_editor_sheet_buttons`. This avoids lost // actions caused by the draggable sheet header consuming the event. @@ -3278,8 +2684,6 @@ impl CadWorkspace { if self.view.button(cx, ids!(fit_button)).clicked(actions) { self.apply_to_all_viewports(cx, |vp, cx| vp.zoom_to_fit(cx)); } - self.handle_outliner_actions(cx, actions); - self.handle_palette_actions(cx, actions); if self.view.button(cx, ids!(grow_button)).clicked(actions) { self.apply_to_all_viewports(cx, |vp, cx| vp.resize_selected(cx, 1.2, 1.2, 1.2)); } @@ -3325,97 +2729,6 @@ impl CadWorkspace { } } -impl CadWorkspace { - /// Toggle the project dashboard / editor overlay layers to match - /// `self.show_dashboard`. The dashboard is a full-size child of the - /// root view that sits above the Desktop and Mobile variants, so it - /// only needs to be made visible/hidden; the editor layers are hidden - /// so they stop processing touches while the dashboard is shown. - fn apply_dashboard_visibility(&mut self, cx: &mut Cx) { - for dash_id in [ids!(Desktop), ids!(Mobile)] { - if let Some(mut w) = self.view.widget(cx, dash_id).borrow_mut::() { - w.set_visible(cx, !self.show_dashboard); - } - } - if let Some(mut d) = self - .view - .widget(cx, ids!(dashboard)) - .borrow_mut::() - { - d.set_dash_visible(cx, self.show_dashboard); - // Only refresh/re-list (which redraws) when we *transition* onto - // the dashboard, not on every frame while it stays visible. - if self.show_dashboard && !self.dashboard_prev_visible { - d.refresh_and_redraw(cx); - } - } - self.dashboard_prev_visible = self.show_dashboard; - } - - /// Check the dashboard for pending actions (new project, open - /// project) and dispatch them, flipping the editor into place. - fn handle_dashboard_actions(&mut self, cx: &mut Cx) { - let pending_action: Option< - crate::construction_frame::pages::workspace::cad::dashboard::CadAction, - > = { - let widget_ref = self.view.widget(cx, ids!(dashboard)); - let Some(mut dashboard) = - widget_ref - .borrow_mut::() - else { - return; - }; - dashboard.action.take() - }; - - let Some(action) = pending_action else { return }; - - match action { - crate::construction_frame::pages::workspace::cad::dashboard::CadAction::NewProject => { - let project = crate::cad_store::create_cad_project( - "Untitled CAD Project", - "Construction", - "", - ); - self.current_prompt_title = project.name.clone(); - self.set_editor_text_all(cx, DEFAULT_CAD_SCRIPT); - self.last_source = DEFAULT_CAD_SCRIPT.to_string(); - self.update_prompt_title(cx); - self.show_dashboard = false; - self.request_rebuild(cx, true, true); - self.view.redraw(cx); - } - crate::construction_frame::pages::workspace::cad::dashboard::CadAction::OpenProject( - id, - ) => { - let Some(source) = crate::cad_store::load_cad_script(&id).ok() else { - return; - }; - let name = crate::project_store::load_projects() - .into_iter() - .find(|p| p.id == id) - .map(|p| p.name) - .unwrap_or_else(|| id.clone()); - crate::cad_store::set_active_project(crate::cad_store::ActiveProject { - id: id.clone(), - name: name.clone(), - }); - self.current_prompt_title = name; - self.set_editor_text_all(cx, &source); - self.last_source = source; - self.update_prompt_title(cx); - self.show_dashboard = false; - self.request_rebuild(cx, true, true); - self.view.redraw(cx); - } - crate::construction_frame::pages::workspace::cad::dashboard::CadAction::BackToDashboard => { - self.show_dashboard = true; - self.view.redraw(cx); - } - } - } -} - impl Widget for CadWorkspace { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { // Log window geometry changes for screen-size transition debugging @@ -3428,11 +2741,6 @@ impl Widget for CadWorkspace { } // Workspace-level keyboard shortcuts (undo/redo) if let Event::KeyDown(ke) = event { - // F1: toggle the keymap help overlay (no modifier). - if matches!(ke.key_code, makepad_platform::KeyCode::F1) { - self.toggle_keymap(cx, !self.keymap_open); - return; - } if ke.modifiers.is_primary() { match ke.key_code { makepad_platform::KeyCode::KeyZ => { @@ -3447,39 +2755,10 @@ impl Widget for CadWorkspace { } return; } - makepad_platform::KeyCode::KeyP => { - self.toggle_palette(cx, !self.palette_open); - return; - } - makepad_platform::KeyCode::KeyK => { - if ke.modifiers.shift { - self.apply_to_all_viewports(cx, |vp, cx| vp.show_all(cx)); - } else { - self.apply_to_all_viewports(cx, |vp, cx| vp.hide_selected(cx)); - } - return; - } - _ => {} - } - } - if ke.modifiers.alt && !ke.modifiers.shift { - match ke.key_code { - makepad_platform::KeyCode::KeyZ => { - self.toggle_xray(cx); - return; - } - makepad_platform::KeyCode::KeyH => { - self.apply_to_all_viewports(cx, |vp, cx| vp.toggle_all_visibility(cx)); - return; - } _ => {} } } } - // Wheel/trackpad scroll over a numeric properties field steps its - // value (scroll-delta stepper, Phase H). Runs before the live view so - // it can consume the unhandled vertical scroll first. - self.handle_numeric_scroll_stepper(cx, event); self.handle_direct_editor_sheet_buttons(cx, event); let is_next_frame = self.next_frame.is_event(event).is_some(); // Push the bottom sheet's screen rect into viewports so they can @@ -3497,7 +2776,6 @@ impl Widget for CadWorkspace { self.view.handle_event(cx, event, scope); if is_next_frame { self.sync_view_from_any_dirty_viewport(cx); - self.sync_selection_properties(cx); } self.update_active_pane_from_pointer_event(cx, event); @@ -3568,7 +2846,6 @@ impl Widget for CadWorkspace { } Event::Actions(actions) => { self.handle_actions(cx, actions); - self.handle_dashboard_actions(cx); } _ => {} } @@ -3605,7 +2882,6 @@ impl Widget for CadWorkspace { if self.initialized { self.drain_rebuild_results(cx.cx); } - self.apply_dashboard_visibility(cx.cx); self.view.draw_walk(cx, scope, walk) } } @@ -3827,29 +3103,6 @@ mod properties_panel_setter_tests { } } -#[cfg(test)] -mod fmt_num_tests { - use super::CadWorkspace; - - #[test] - fn whole_values_drop_trailing_zeros() { - assert_eq!(CadWorkspace::fmt_num(42.0), "42"); - assert_eq!(CadWorkspace::fmt_num(3.0 + 1e-11), "3"); - } - - #[test] - fn fractional_values_keep_two_decimals() { - assert_eq!(CadWorkspace::fmt_num(0.5), "0.50"); - assert_eq!(CadWorkspace::fmt_num(1.25), "1.25"); - } - - #[test] - fn negative_and_large_values_format_stably() { - assert_eq!(CadWorkspace::fmt_num(-7.0), "-7"); - assert_eq!(CadWorkspace::fmt_num(123.456), "123.46"); - } -} - #[cfg(test)] mod save_status_tests { use super::save_status_message; diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cost_estimator/cost_estimate_screen.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cost_estimator/cost_estimate_screen.rs index d1b860e..86bc893 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/cost_estimator/cost_estimate_screen.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/cost_estimator/cost_estimate_screen.rs @@ -230,71 +230,64 @@ script_mod! { width: 400.0 height: Fit flow: Down - spacing: 0.0 + spacing: 12.0 padding: 20.0 show_bg: true draw_bg +: { color: #x18181A border_radius: 14.0 border_size: 1.0 border_color: #x2D3642 } - modal_content := View { + modal_title := Label { + text: "Add Room" + draw_text +: { color: (COST_TEXT) text_style: theme.font_bold { font_size: 15.0 } } + } + + room_type_picker := mod.widgets.RoomTypePicker {} + + size_row := View { + width: Fill, height: Fit + flow: Right, spacing: 8.0 + align: Align{y: 0.5} + + size_label := Label { + text: "Size" + width: 60.0, height: Fit + draw_text +: { color: (COST_MUTED) text_style: theme.font_regular { font_size: 11.0 } } + } + size_category_dropdown := mod.widgets.CategoryDropdown { + width: Fill, height: 32.0 + } + } + + add_room_length := TextInput { + width: Fill, height: 32.0 + empty_text: "Length (m)" + draw_text +: { color: (COST_TEXT) text_style: theme.font_regular { font_size: 11.0 } } + } + add_room_width := TextInput { + width: Fill, height: 32.0 + empty_text: "Width (m)" + draw_text +: { color: (COST_TEXT) text_style: theme.font_regular { font_size: 11.0 } } + } + add_room_count := TextInput { + width: Fill, height: 32.0 + empty_text: "Count" + text: "1" + draw_text +: { color: (COST_TEXT) text_style: theme.font_regular { font_size: 11.0 } } + } + + rate_row := View { width: Fill, height: Fit flow: Down - spacing: 12.0 + spacing: 4.0 - modal_title := Label { - text: "Add Room" - draw_text +: { color: (COST_TEXT) text_style: theme.font_bold { font_size: 15.0 } } - } - - room_type_picker := mod.widgets.RoomTypePicker {} - - size_row := View { - width: Fill, height: Fit - flow: Right, spacing: 8.0 - align: Align{y: 0.5} - - size_label := Label { - text: "Size" - width: 60.0, height: Fit - draw_text +: { color: (COST_MUTED) text_style: theme.font_regular { font_size: 11.0 } } - } - size_category_dropdown := mod.widgets.CategoryDropdown { - width: Fill, height: 32.0 - } - } - - add_room_length := TextInput { - width: Fill, height: 32.0 - empty_text: "Length (m)" - draw_text +: { color: (COST_TEXT) text_style: theme.font_regular { font_size: 11.0 } } - } - add_room_width := TextInput { - width: Fill, height: 32.0 - empty_text: "Width (m)" - draw_text +: { color: (COST_TEXT) text_style: theme.font_regular { font_size: 11.0 } } - } - add_room_count := TextInput { - width: Fill, height: 32.0 - empty_text: "Count" - text: "1" - draw_text +: { color: (COST_TEXT) text_style: theme.font_regular { font_size: 11.0 } } - } - - rate_row := View { - width: Fill, height: Fit - flow: Down - spacing: 4.0 - - rate_label := Label { - text: "Rate (KES/m²)" - draw_text +: { color: (COST_MUTED) text_style: theme.font_regular { font_size: 11.0 } } - } + rate_label := Label { + text: "Rate (KES/m²)" + draw_text +: { color: (COST_MUTED) text_style: theme.font_regular { font_size: 11.0 } } } } modal_buttons := View { width: Fill, height: Fit flow: Right, spacing: 8.0, align: Align{y: 0.5} - padding: { top: 12.0 } add_room_cancel_btn := Button { width: Fill, height: 36.0 @@ -424,13 +417,13 @@ script_mod! { } } - meta_label := mod.widgets.CostEstimatorMutedLabel { text: "Currency: Ksh" } - room_list := mod.widgets.RoomList { width: Fill height: Fill } + meta_label := mod.widgets.CostEstimatorMutedLabel { text: "Currency: Ksh" } + add_room_modal := mod.widgets.AddRoomModal {} } } diff --git a/crates/apps/doc/doc-ui/COVERAGE.md b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/COVERAGE.md similarity index 100% rename from crates/apps/doc/doc-ui/COVERAGE.md rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/COVERAGE.md diff --git a/crates/apps/doc/doc-ui/DEVICE_VERIFICATION.md b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/DEVICE_VERIFICATION.md similarity index 71% rename from crates/apps/doc/doc-ui/DEVICE_VERIFICATION.md rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/DEVICE_VERIFICATION.md index 4ffd739..862370d 100644 --- a/crates/apps/doc/doc-ui/DEVICE_VERIFICATION.md +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/DEVICE_VERIFICATION.md @@ -232,73 +232,20 @@ init:` lines in logcat naming the branch that fired. ## 10. Sign-off -Device / OS / build: Galaxy A60 (SM-A6060, `R28M52LJP2Y`) · Android · -`pageflipnav` release APK, driven by the `doc_*` tests in -`crates/pageflipnav/tests/ui.rs`. All 14 tests PASS on device (8 -nav/state + 6 content-operation: `doc_type_text_inserts_document`, -`doc_bold_italic_underline_ops`, `doc_insert_table_and_cell_text`, -`doc_merge_split_cell_ops`, `doc_long_press_empty_space_does_not_arm`, -`doc_diagonal_cell_range_merges`, plus `doc_diag_*` used to pin geometry). +Device / OS / build: -Verified devices: Galaxy A60 (SM-A6060, `R28M52LJP2Y`, 411 dp) and Galaxy -A16 (SM-A165F, `RF8Y103NERA`, 384 dp). One A60 full-suite run had 3 -`adb: device not found` USB drops (test-infra flakes, not logic); all 3 -reran clean on the A16. +| Section | CRDT workspace | Legacy DocWorkspace | Notes | +|---------|----------------|---------------------|-------| +| 1 Interaction mode | | | | +| 2 IME | | | | +| 3 Long-press + menu | | | | +| 4 Table gestures | | | | +| 5 Scroll handoff | | | the open roadmap box — closing needs BOTH columns | +| 6 Clipboard round-trips | | n/a | payload semantics are engine-agnostic | +| 7 Multi-line rendering | | | | +| 8 Visual sweep | | | | +| 9 Persistence | | | | -Coverage legend: **A** = automated (ran green on device), -**A⚠** = automated but only proves a subset, **M** = manual-only (cannot -be driven by `makepad_test` — reason in the Note column). - -| # | Row | Result | Test / Note | -|---|-----|--------|-------------| -| 1.1 | Cold-launch tap → no keyboard | A | `doc_view_mode_scroll_by_touch` (View cold-release path); first tap never opens IME | -| 1.2 | Vertical drag → page scrolls | A | `doc_view_mode_scroll_by_touch` | -| 1.3 | Edit button relabels Done; tap opens IME | A | `doc_interaction_mode_view_and_edit`, `doc_ime_text_input_in_edit_mode` | -| 1.4 | Done closes keyboard | A⚠ | `doc_interaction_mode_view_and_edit` relabels back to Edit; keyboard visibility itself is not snapshot-observable, so "closes" is inferred from the Edit state + subsequent passive taps | -| 2.1 | Edit: tap mid-word, type | A | `doc_type_text_inserts_document` (stats `2 words | 11 chars`) | -| 2.2 | Tap in a cell, type | A | `doc_insert_table_and_cell_text` (cell "alpha beta" joins stats → `4 words | 21 chars`) | -| 2.3 | Accept autocorrect suggestion | M | `makepad_test` cannot press the OS keyboard's suggestion bar; needs a human tap | -| 2.4 | Type in empty cell, undo once | A⚠ | Undo not asserted on device; covered by `doc-ui` unit `runtime_cell_backspace_edits_cell_and_ctrl_z_restores_it` | -| 2.5 | Long word overflows cell | M | Visual single-line overflow check — note only, eyeball | -| 3.1 | Long-press word → word selects | A⚠ | `doc_long_press_arms_selection` proves the arm runs and the doc stays alive; handle/menu pixels are visual | -| 3.2 | Long-press with keyboard open | M | Cannot open/clamp the OS keyboard from the harness | -| 3.3 | Drag start/end handle | M | Handle hit-testing + native menu placement need visual confirmation | -| 3.4 | Long-press empty space → nothing arms | A | `doc_long_press_empty_space_does_not_arm` | -| 3.5 | Long-press cell → cell range arms | A | arming used by `doc_merge_split_cell_ops` (drag spans col1) | -| 3.6 | Menu re-floats after handle drag | M | Native menu is OS-owned, not a snapshot-able widget | -| 4.1 | Diagonal 2x2 range + highlight | A | `doc_diagonal_cell_range_merges` | -| 4.2 | Merge; undo in one step | A | `doc_merge_split_cell_ops` (merge); undo is unit-covered | -| 4.3 | Split merged cell | A | `doc_merge_split_cell_ops` (`Split merged cell`) | -| 4.4 | Drag past table edge clamps | A⚠ | Merge path proves the range stays in-col; out-of-table clamp is unit-covered | -| 4.5 | Paste range into second table | M | Requires system clipboard content + external app (section 6) | -| 5.1 | View drag → pans | A | `doc_view_mode_scroll_by_touch` | -| 5.2 | Edit quick drag → pans, no selection | A | `doc_scroll_handoff_view_and_edit` | -| 5.3 | Selection armed, lift, then drag scrolls | M | Requires a held selection + OS interactions not snapshot-able; unit/gesture-covered in `doc-ui` | -| 5.4 | Hold after long-press → selection tracks | M | Gesture requires frame-true finger sequencing only partially reproducible; covered by `doc-ui` `runtime_*` gesture tests | -| 5.5 | Legacy DocWorkspace 5.1–5.4 | M | Legacy workspace untested; blocked by in-progress user work (`nigig-build`, `xls_import`) | -| 5.6 | End-of-content rubber-band | M | Platform convention, visual | -| 6.1–6.4 | Clipboard round-trips | M | Needs `adb` clipboard + an external notes app to paste INTO — no harness API for system clipboard reads/veto | -| 7.1–7.4 | Multi-line cell rendering | M | Visual inspection of row growth / caret bands / overlap | -| 8.1–8.6 | Visual paint/clip sweep | M | GPU painting — eyeball, photograph failures | -| 9.0 | Fresh install demo doc | M | Needs app data wipe (reinstall) between launches; harness can't reset app-data | -| 9.1 | Force-close + relaunch restores | M | Harness cannot kill/relaunch the process to exercise the load path | -| 9.2 | Open saved file with table | M | Same process-restart limitation | -| 9.3 | Classic-format save fallback | M | Needs a pre-written classic save on the device + relaunch; unit-covered elsewhere | - -Two real mobile-only bugs were found and fixed by these device runs -(unit-testable parts covered in `doc-ui/src/tests.rs`): -1. The CRDT/legacy toolbars overflowed the ~411 dp phone screen, - clipping Italic and pushing Underline/Table/Merge/Split off-screen — - the toolbars now wrap (`flow: Right {wrap: true}` in `doc-ui/src/lib.rs`). -2. `insert_table` produced a zero-size, un-typeable table — it now seeds a - 2x2 grid and parks the caret in the top-left cell (`crdt_widget.rs`, - covered by `runtime_insert_table_seeds_default_grid_and_parks_caret_in_first_cell`). - -Closing notes: -- The roadmap's ScrollYView handoff box (section 5) is NOT closed: most - of section 5 and the entire legacy `DocWorkspace` column remain manual - (5.3, 5.4, 5.5, 5.6). -- Rows still open: 2.3, 2.5, 3.1 (pixels), 3.2, 3.3, 3.5/3.6 (menu), - 4.4 (pixels), 4.5, 5.3–5.6 (legacy + gestures), 6 (clipboard), 7 - (visual), 8 (visual), 9 (process-restart). Most are genuinely not - automatable through `makepad_test`; the reasons are in the table. +When every row is PASS: check the roadmap's ScrollYView handoff box +with a link to this file, and archive the completed form under the +milestone notes in the README. diff --git a/crates/apps/doc/doc-ui/README.md b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md similarity index 100% rename from crates/apps/doc/doc-ui/README.md rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/Summary of the V2 Architecture b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/Summary of the V2 Architecture new file mode 100644 index 0000000..21ba58a --- /dev/null +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/Summary of the V2 Architecture @@ -0,0 +1,82 @@ +Summary of the V2 Architecture +Strict Boundaries: Widget handles events -> Editor generates Commands -> Document is mutated -> LayoutEngine computes geometry -> Renderer draws. +Memory Safe History: Undo/Redo no longer stores whole JSON strings. It calculates inverse commands, resulting in O(1) tiny allocations per keystroke. +Decoupled Hit Testing: The LayoutEngine owns layout coordinates and hit-testing. The widget just asks layout_engine.hit_test(local_pos) and gets a clean Cursor back. +Extensibility: Because tables contain Vec, you can now put images, lists, and even nested tables inside a table cell. The prototype was hardcoded to flat strings. +To complete the production polish (the remaining 5%), you would: + +Implement true font measurement by passing Cx2d into FontMeasurer (or caching a Font and using get_text_size). +Flesh out ArrowLeft and ArrowRight navigation in the Editor by asking the LayoutEngine for the previous/next visual run. +Add the Toolbar back, wiring its buttons to editor.toggle_bold() etc. +This architecture will scale beautifully to infinite canvas, collaborative editing, and embedded widgets + + +Current state +The document engine milestones are now an architectural base, but the following still need full end-to-end implementation rather than only foundation types: + +granular command execution for every edit path; +command coalescing for typing; +full persistent layout reuse/invalidation; +moving table, image, divider, and advanced-node rendering fully into DocumentRenderer; +actual nested advanced-block editing; +embedding live Makepad widgets through the registry; +remote operation application and conflict resolution/CRDT behavior. +The next correct step after verifying mobile typing and drag selection is to wire granular commands into keyboard input and toolbar operations, rather than introducing more block types. + + +## Remaining task list + +### Command engine + +- [x] Text insert/delete +- [x] Same-span, cross-span, and cross-block replacement +- [x] Backspace/Delete +- [x] Paragraph split/merge in both directions +- [x] Style range operations +- [x] Alignment +- [x] Block insertion/removal +- [x] Table-cell typing/deletion +- [x] Table row insertion/removal command +- [x] Image property update +- [x] Typing coalescing +- [ ] Cut/copy/paste command integration +- [ ] Full table-column commands +- [ ] Table-cell merge/split commands +- [ ] Advanced-node editing UI commands + +### Layout and rendering + +- [x] Persistent layout-tree foundation +- [x] Renderer boundary foundation +- [ ] Real incremental reflow/invalidation by changed block +- [ ] Move table/image/divider rendering fully into `DocumentRenderer` +- [ ] Render CRDT remote cursor/selection presence +- [ ] Advanced-block hit testing and editing +- [ ] True multi-page pagination and page reflow + +### Mobile interaction + +- [x] View/Edit mode foundation +- [x] Long press selection +- [x] Selection handles +- [ ] Final mobile viewport gesture arbitration with `ScrollYView` +- [ ] AdaptiveView desktop/mobile toolbar layout +- [ ] Native mobile copy/share/select-all actions + +### CRDT and collaboration + +- [x] Stable AtomId / BlockId / Lamport primitives +- [x] Deterministic legacy text bootstrap +- [x] CRDT local insert/delete +- [x] CRDT selected-range delete/replace +- [x] CRDT cursor/selection identity foundation +- [x] CRDT block-order primitives and anchored block commands +- [x] Remote operation buffering +- [x] Concurrent CRDT-safe operation application +- [x] Remote presence mapping foundation +- [x] Acknowledgment frontier and tombstone-compaction foundation +- [ ] Record tombstone deletion timestamps +- [ ] Persist/restore CRDT metadata and tombstones +- [ ] Full peer synchronization protocol +- [ ] CRDT-aware table and advanced-block structures +- [ ] Visual remote selections/cursors \ No newline at end of file diff --git a/crates/apps/doc/doc-ui/src/advanced_json.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/advanced_json.rs similarity index 99% rename from crates/apps/doc/doc-ui/src/advanced_json.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/advanced_json.rs index 081cdca..b249a53 100644 --- a/crates/apps/doc/doc-ui/src/advanced_json.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/advanced_json.rs @@ -1,6 +1,6 @@ //! Versioned JSON persistence for advanced nodes. Unsupported node kinds are //! rejected rather than silently dropped. -use crate::model::{ +use crate::construction_frame::pages::workspace::doc::model::{ BlockKind, CanvasNode, CanvasObject, CellStyle, DocumentNode, EmbeddedWidgetNode, ImageNode, Inline, ListItem, TableBorders, TableCellModel, TableColumn, TableModel, TableRow, }; @@ -684,7 +684,7 @@ impl TryFrom<&Inline> for PersistedInline { impl TryFrom for Inline { type Error = String; fn try_from(inline: PersistedInline) -> Result { - use crate::model::StyleSpan; + use crate::construction_frame::pages::workspace::doc::model::StyleSpan; Ok(match inline { PersistedInline::Text { text, diff --git a/crates/apps/doc/doc-ui/src/collaboration/mod.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/collaboration/mod.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/collaboration/mod.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/collaboration/mod.rs diff --git a/crates/apps/doc/doc-ui/src/collaboration/operation.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/collaboration/operation.rs similarity index 85% rename from crates/apps/doc/doc-ui/src/collaboration/operation.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/collaboration/operation.rs index 6fe39e2..a4235da 100644 --- a/crates/apps/doc/doc-ui/src/collaboration/operation.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/collaboration/operation.rs @@ -1,4 +1,4 @@ -use crate::editing::Transaction; +use crate::construction_frame::pages::workspace::doc::editing::Transaction; pub type ActorId = String; diff --git a/crates/apps/doc/doc-ui/src/collaboration/presence.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/collaboration/presence.rs similarity index 89% rename from crates/apps/doc/doc-ui/src/collaboration/presence.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/collaboration/presence.rs index 28472d7..a1be889 100644 --- a/crates/apps/doc/doc-ui/src/collaboration/presence.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/collaboration/presence.rs @@ -1,5 +1,5 @@ use super::ActorId; -use crate::model::{ +use crate::construction_frame::pages::workspace::doc::model::{ CrdtSelection, CrdtTextPosition, DocCursor, Selection, }; diff --git a/crates/apps/doc/doc-ui/src/collaboration/session.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/collaboration/session.rs similarity index 96% rename from crates/apps/doc/doc-ui/src/collaboration/session.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/collaboration/session.rs index 471b95f..d921505 100644 --- a/crates/apps/doc/doc-ui/src/collaboration/session.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/collaboration/session.rs @@ -37,7 +37,7 @@ impl CollaborationSession { pub fn make_local( &mut self, base_revision: u64, - transaction: crate::editing::Transaction, + transaction: crate::construction_frame::pages::workspace::doc::editing::Transaction, ) -> DocumentOperation { self.next_sequence = self.next_sequence.wrapping_add(1); let id = OperationId { diff --git a/crates/apps/doc/doc-ui/src/collaboration/transport.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/collaboration/transport.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/collaboration/transport.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/collaboration/transport.rs diff --git a/crates/apps/doc/doc-ui/src/crdt_bridge.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_bridge.rs similarity index 65% rename from crates/apps/doc/doc-ui/src/crdt_bridge.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_bridge.rs index f44bd21..1978e53 100644 --- a/crates/apps/doc/doc-ui/src/crdt_bridge.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_bridge.rs @@ -1,6 +1,6 @@ //! Temporary bridge from the standalone CRDT engine into the existing Makepad //! document UI. Remove this once DocEditor consumes projections directly. -use crate::model::{ +use crate::construction_frame::pages::workspace::doc::model::{ DocAlign, DocBlock, Document, StyleSpan, }; use doc_engine::projection::DocumentProjection; @@ -16,7 +16,7 @@ impl CrdtProjectionBridge { let cols = table.columns.len().max(1); let cells = (0..rows).map(|row| (0..cols).map(|col| { let key = (table.rows.get(row).cloned().unwrap_or_default(), table.columns.get(col).cloned().unwrap_or_default()); - crate::model::CellContent { text: table.cells.get(&key).cloned().unwrap_or_default() } + crate::construction_frame::pages::workspace::doc::model::CellContent { text: table.cells.get(&key).cloned().unwrap_or_default() } }).collect()).collect(); return DocBlock::Table { rows, cols, col_widths: vec![160.0; cols], cells }; } @@ -35,15 +35,15 @@ impl CrdtProjectionBridge { }).collect(); document.nodes = projection.nodes.iter().enumerate().map(|(index, node)| { let kind = match node.kind.as_str() { - "canvas" => crate::model::BlockKind::Canvas(crate::model::CanvasNode { size: makepad_widgets::dvec2(400.0, 240.0), objects: Vec::new() }), - "divider" => crate::model::BlockKind::Divider, - "audio" => crate::model::BlockKind::Audio { resource: node.state_json.clone() }, - "video" => crate::model::BlockKind::Video { resource: node.state_json.clone() }, - "diagram" => crate::model::BlockKind::Diagram { resource: node.state_json.clone() }, - "image" => crate::model::BlockKind::Image(crate::model::ImageNode { resource: node.state_json.clone(), size: makepad_widgets::dvec2(480.0, 220.0), caption: Vec::new() }), - _ => crate::model::BlockKind::EmbeddedWidget(crate::model::EmbeddedWidgetNode { widget_type: node.kind.clone(), state_json: node.state_json.clone(), preferred_size: None }), + "canvas" => crate::construction_frame::pages::workspace::doc::model::BlockKind::Canvas(crate::construction_frame::pages::workspace::doc::model::CanvasNode { size: makepad_widgets::dvec2(400.0, 240.0), objects: Vec::new() }), + "divider" => crate::construction_frame::pages::workspace::doc::model::BlockKind::Divider, + "audio" => crate::construction_frame::pages::workspace::doc::model::BlockKind::Audio { resource: node.state_json.clone() }, + "video" => crate::construction_frame::pages::workspace::doc::model::BlockKind::Video { resource: node.state_json.clone() }, + "diagram" => crate::construction_frame::pages::workspace::doc::model::BlockKind::Diagram { resource: node.state_json.clone() }, + "image" => crate::construction_frame::pages::workspace::doc::model::BlockKind::Image(crate::construction_frame::pages::workspace::doc::model::ImageNode { resource: node.state_json.clone(), size: makepad_widgets::dvec2(480.0, 220.0), caption: Vec::new() }), + _ => crate::construction_frame::pages::workspace::doc::model::BlockKind::EmbeddedWidget(crate::construction_frame::pages::workspace::doc::model::EmbeddedWidgetNode { widget_type: node.kind.clone(), state_json: node.state_json.clone(), preferred_size: None }), }; - crate::model::DocumentNode { id: (index + 1) as u64, style: Default::default(), kind } + crate::construction_frame::pages::workspace::doc::model::DocumentNode { id: (index + 1) as u64, style: Default::default(), kind } }).collect(); let projected_blocks = document.blocks.clone(); let node_ref = |id: &str| { @@ -77,7 +77,7 @@ impl CrdtProjectionBridge { ) { if let Some(block_idx) = projection.order.iter().position(|id| id == &table.id) { - document.table_merges.push(crate::model::TableMerge { selection: crate::model::TableSelection { block_idx, start_row, start_col, end_row, end_col }.normalized() }); + document.table_merges.push(crate::construction_frame::pages::workspace::doc::model::TableMerge { selection: crate::construction_frame::pages::workspace::doc::model::TableSelection { block_idx, start_row, start_col, end_row, end_col }.normalized() }); } } } diff --git a/crates/apps/doc/doc-ui/src/crdt_widget.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_widget.rs similarity index 96% rename from crates/apps/doc/doc-ui/src/crdt_widget.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_widget.rs index 02d10b9..d381f90 100644 --- a/crates/apps/doc/doc-ui/src/crdt_widget.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/crdt_widget.rs @@ -1,4 +1,4 @@ -use crate::persistence::load_saved_doc_state; +use crate::construction_frame::pages::workspace::doc::persistence::load_saved_doc_state; use makepad_widgets::makepad_platform::event::TouchState; use makepad_widgets::*; use std::cell::RefCell; @@ -6,7 +6,7 @@ use std::rc::Rc; // NOTE: imported rather than referenced as a fully-qualified path below. // The `Script`/`Widget` derive macros cannot parse a path type (`a::b::C`) // in a `#[rust]` field and reject it with "Unexpected field form". -use crate::projection_layout::{ +use crate::construction_frame::pages::workspace::doc::projection_layout::{ block_glyph_offset, cell_char_offset_at, cell_range_mergeable, cell_selection_rects, cell_text_backspace, cell_text_delete, cell_text_insert, cell_text_line_col, cell_text_line_count, cell_text_offset_at, cell_text_replace_range, cell_text_span_rects, @@ -15,11 +15,11 @@ use crate::projection_layout::{ table_cell_caret, table_cell_cursor_at, table_cell_position, table_cell_range, table_cell_text, word_atom_range, ProjectionLayoutTree, SelectionHandles, }; -use crate::projection_renderer::ProjectionRenderer; -use crate::projection_session::{ +use crate::construction_frame::pages::workspace::doc::projection_renderer::ProjectionRenderer; +use crate::construction_frame::pages::workspace::doc::projection_session::{ crdt_engine_from_saved, crdt_save_wire, ProjectionSession, TableCellCursor, TableCellSelection, }; -use crate::{ +use crate::construction_frame::pages::workspace::doc::{ InteractionMode, MobileGestureAction, MobileGestureRouter, MobileGestureState, }; use doc_engine::controller::DocumentController; @@ -123,23 +123,6 @@ pub struct CrdtDocEditor { layout_cache: RefCell)>>, } -/// Total content height of a projection layout, from the top margin down to -/// the bottom-most glyph/table/node. Used to resolve the editor's Fit walk -/// into a concrete Fixed height for `walk_turtle`. -fn content_height(layout: &ProjectionLayoutTree) -> f64 { - let mut bottom = 0.0_f64; - for glyph in &layout.glyphs { - bottom = bottom.max(glyph.rect.pos.y + glyph.rect.size.y); - } - for table in &layout.tables { - bottom = bottom.max(table.rect.pos.y + table.rect.size.y); - } - for node in &layout.nodes { - bottom = bottom.max(node.rect.pos.y + node.rect.size.y); - } - (bottom + crate::projection_layout::LAYOUT_MARGIN).max(1.0) -} - impl CrdtDocEditor { pub fn set_engine(&mut self, cx: &mut Cx, engine: doc_engine::controller::DocumentController) { self.engine = engine; @@ -266,49 +249,9 @@ impl CrdtDocEditor { /// legacy toolbar's CRDT InsertBlock-table routing. pub fn insert_table(&mut self, cx: &mut Cx) -> bool { let after = self.session.cursor_block.clone(); - let Some(table) = self.engine.insert_table("local", after) else { + if self.engine.insert_table("local", after).is_none() { return false; - }; - // Seed a default 2x2 grid so the table is visible and usable: - // a bare table block carries no rows/columns/cells and the layout - // (driven purely by their counts) renders it at zero size. Park - // the caret in the first cell so typing lands in the table, matching - // the legacy editor's insert behavior. - let mut rows = Vec::new(); - for _ in 0..2 { - if let Some(row) = self.engine.insert_table_row("local", table.clone(), None) { - rows.push(row); - } } - let mut cols = Vec::new(); - for _ in 0..2 { - let after = cols.last().cloned(); - if let Some(col) = self.engine.insert_table_column("local", table.clone(), after) { - cols.push(col); - } - } - // Rows/columns anchored on the same `after` serialize counter- - // descending, so the visual first cell is the LAST-inserted row and - // the FIRST column of the seed. Park the caret there (top-left). - let row_id = rows.pop().or_else(|| rows.first().cloned()); - let col_id = cols.first().cloned(); - if let (Some(row_id), Some(col_id)) = (row_id, col_id) { - if self - .engine - .set_table_cell("local", table.clone(), row_id.clone(), col_id.clone(), "") - { - self.session.cell_cursor = Some(TableCellCursor { - table, - row: row_id, - column: col_id, - offset: 0, - }); - } - } - self.session.cell_selection = None; - self.session.cell_text_anchor = None; - self.session.cursor_block = None; - self.session.cursor_atom = None; self.redraw(cx); true } @@ -2774,28 +2717,7 @@ impl Widget for CrdtDocEditor { if self.engine.projection.blocks.is_empty() { self.engine.insert_block("local", None, "paragraph"); } - // Adopt the mobile interaction policy at boot (mirroring the legacy - // DocEditor): a phone-width window starts in View — IME closed, the - // Edit/Done toolbar button re-enters Edit; wide/desktop stays Edit. - if !self.mobile_mode_initialized && cx.cx.display_context.is_screen_size_known() { - self.interaction_mode = if cx.cx.display_context.screen_size.x < 700.0 { - InteractionMode::View - } else { - InteractionMode::Edit - }; - self.mobile_mode_initialized = true; - } - let layout = self.layout_tree(); - // Fit-height custom widgets have no intrinsic height makepad can - // resolve, so walk_turtle would yield a NaN height and the editor - // collapses to an invisible 0x0. Resolve the content height from - // the projection layout (mirrors the legacy DocEditor) and ask for - // that concrete size before walking. - let mut fixed_walk = walk; - if let Size::Fit { .. } = walk.height { - fixed_walk.height = Size::Fixed(content_height(&layout)); - } - let rect = cx.walk_turtle(fixed_walk); + let rect = cx.walk_turtle(walk); self.draw_bg.draw_abs(cx, rect); let layout = self.layout_tree(); ProjectionRenderer::draw_text_projection( diff --git a/crates/apps/doc/doc-ui/src/editing/commands.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/commands.rs similarity index 99% rename from crates/apps/doc/doc-ui/src/editing/commands.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/commands.rs index 8acd51c..9b3c9ae 100644 --- a/crates/apps/doc/doc-ui/src/editing/commands.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/commands.rs @@ -1,4 +1,4 @@ -use crate::model::{ +use crate::construction_frame::pages::workspace::doc::model::{ AtomId, BlockId, CellContent, DocAlign, DocBlock, DocCursor, Document, DocumentNode, DocumentSession, RgaText, StyleSpan, TableMerge, TableSelection, TextAtom, }; @@ -482,7 +482,7 @@ impl Command { atom.after = anchor; } else { document.block_order.insert( - crate::model::BlockAtom { + crate::construction_frame::pages::workspace::doc::model::BlockAtom { id: id.clone(), after: anchor, deleted: false, diff --git a/crates/apps/doc/doc-ui/src/editing/controller.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/controller.rs similarity index 97% rename from crates/apps/doc/doc-ui/src/editing/controller.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/controller.rs index e77a9d6..4525154 100644 --- a/crates/apps/doc/doc-ui/src/editing/controller.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/controller.rs @@ -1,8 +1,8 @@ use super::{Command, History, Transaction}; -use crate::collaboration::{ +use crate::construction_frame::pages::workspace::doc::collaboration::{ AckMessage, CollaborationSession, CollaborationTransport, DocumentOperation, }; -use crate::model::{ +use crate::construction_frame::pages::workspace::doc::model::{ DocBlock, DocCursor, Document, DocumentSession, RgaText, TextAtom, }; @@ -223,7 +223,7 @@ impl DocumentController { fn crdt_position_for( &self, cursor: DocCursor, - ) -> Option { + ) -> Option { if cursor.cell_pos.is_some() { return None; } @@ -245,7 +245,7 @@ impl DocumentController { _ => None, }; Some( - crate::model::CrdtTextPosition { + crate::construction_frame::pages::workspace::doc::model::CrdtTextPosition { block_idx: cursor.block_idx, span_idx: cursor.span_idx, after, @@ -424,7 +424,7 @@ impl DocumentController { /// Tombstoned/missing anchors fall back to the nearest valid offset. pub fn resolve_crdt_position( &self, - position: &crate::model::CrdtTextPosition, + position: &crate::construction_frame::pages::workspace::doc::model::CrdtTextPosition, ) -> Option { let span = match self.document.blocks.get(position.block_idx) { Some(DocBlock::Paragraph { spans, .. }) | Some(DocBlock::Heading { spans, .. }) => { @@ -458,7 +458,7 @@ impl DocumentController { return; }; let frontier = - crate::model::LamportTimestamp { counter }; + crate::construction_frame::pages::workspace::doc::model::LamportTimestamp { counter }; for block in &mut self.document.blocks { if let DocBlock::Paragraph { spans, .. } | DocBlock::Heading { spans, .. } = block { for span in spans { @@ -472,7 +472,7 @@ impl DocumentController { pub fn apply_remote_presence( &mut self, - mut presence: crate::collaboration::Presence, + mut presence: crate::construction_frame::pages::workspace::doc::collaboration::Presence, ) { if let Some(position) = presence.crdt_cursor.as_ref() { presence.cursor = self.resolve_crdt_position(position); diff --git a/crates/apps/doc/doc-ui/src/editing/editor.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/editor.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/editing/editor.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/editor.rs diff --git a/crates/apps/doc/doc-ui/src/editing/history.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/history.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/editing/history.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/history.rs diff --git a/crates/apps/doc/doc-ui/src/editing/mod.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/mod.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/editing/mod.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/mod.rs diff --git a/crates/apps/doc/doc-ui/src/layout/advanced_layout.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/advanced_layout.rs similarity index 97% rename from crates/apps/doc/doc-ui/src/layout/advanced_layout.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/advanced_layout.rs index 36435c6..bb69a66 100644 --- a/crates/apps/doc/doc-ui/src/layout/advanced_layout.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/advanced_layout.rs @@ -1,4 +1,4 @@ -use crate::model::{BlockKind, DocumentNode}; +use crate::construction_frame::pages::workspace::doc::model::{BlockKind, DocumentNode}; use makepad_widgets::{DVec2, Rect}; /// Geometry for v2 blocks. It is intentionally independent of Makepad widgets; diff --git a/crates/apps/doc/doc-ui/src/layout/block_cache.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/block_cache.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/layout/block_cache.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/block_cache.rs diff --git a/crates/apps/doc/doc-ui/src/layout/block_layout.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/block_layout.rs similarity index 96% rename from crates/apps/doc/doc-ui/src/layout/block_layout.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/block_layout.rs index 0f72904..04c9d41 100644 --- a/crates/apps/doc/doc-ui/src/layout/block_layout.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/block_layout.rs @@ -1,5 +1,5 @@ -use crate::layout::GlyphHit; -use crate::model::{DocAlign, DocCursor, StyleSpan}; +use crate::construction_frame::pages::workspace::doc::layout::GlyphHit; +use crate::construction_frame::pages::workspace::doc::model::{DocAlign, DocCursor, StyleSpan}; use makepad_widgets::{dvec2, Rect}; #[derive(Clone, Debug)] diff --git a/crates/apps/doc/doc-ui/src/layout/divider_layout.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/divider_layout.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/layout/divider_layout.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/divider_layout.rs diff --git a/crates/apps/doc/doc-ui/src/layout/hit_test.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/hit_test.rs similarity index 88% rename from crates/apps/doc/doc-ui/src/layout/hit_test.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/hit_test.rs index d45ccaa..0d40223 100644 --- a/crates/apps/doc/doc-ui/src/layout/hit_test.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/hit_test.rs @@ -1,4 +1,4 @@ -use crate::model::DocCursor; +use crate::construction_frame::pages::workspace::doc::model::DocCursor; use makepad_widgets::{DVec2, Rect}; #[derive(Clone, Debug)] diff --git a/crates/apps/doc/doc-ui/src/layout/image_layout.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/image_layout.rs similarity index 87% rename from crates/apps/doc/doc-ui/src/layout/image_layout.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/image_layout.rs index 54902e0..a27bffa 100644 --- a/crates/apps/doc/doc-ui/src/layout/image_layout.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/image_layout.rs @@ -1,4 +1,4 @@ -use crate::model::DocAlign; +use crate::construction_frame::pages::workspace::doc::model::DocAlign; use makepad_widgets::{dvec2, DVec2, Rect}; pub fn layout_image( diff --git a/crates/apps/doc/doc-ui/src/layout/layout_engine.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/layout_engine.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/layout/layout_engine.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/layout_engine.rs diff --git a/crates/apps/doc/doc-ui/src/layout/layout_tree.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/layout_tree.rs similarity index 98% rename from crates/apps/doc/doc-ui/src/layout/layout_tree.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/layout_tree.rs index 028a322..77a34a5 100644 --- a/crates/apps/doc/doc-ui/src/layout/layout_tree.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/layout_tree.rs @@ -1,5 +1,5 @@ use super::{AdvancedLayoutBlock, GlyphHit, ParagraphFragment, TableFragment}; -use crate::model::{DocCursor, StyleSpan}; +use crate::construction_frame::pages::workspace::doc::model::{DocCursor, StyleSpan}; use makepad_widgets::{dvec2, DVec2, Rect}; /// Persistent, renderer-independent geometry produced by layout. diff --git a/crates/apps/doc/doc-ui/src/layout/mod.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/mod.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/layout/mod.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/mod.rs diff --git a/crates/apps/doc/doc-ui/src/layout/page_cache.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/page_cache.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/layout/page_cache.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/page_cache.rs diff --git a/crates/apps/doc/doc-ui/src/layout/page_layout.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/page_layout.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/layout/page_layout.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/page_layout.rs diff --git a/crates/apps/doc/doc-ui/src/layout/table_layout.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/table_layout.rs similarity index 96% rename from crates/apps/doc/doc-ui/src/layout/table_layout.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/table_layout.rs index b1ada7b..bb716be 100644 --- a/crates/apps/doc/doc-ui/src/layout/table_layout.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/layout/table_layout.rs @@ -72,8 +72,8 @@ pub struct TableRenderCell { /// Builds renderable table cells from base geometry plus merge metadata. pub fn build_table_render_cells( fragment: &TableFragment, - cells: &[Vec], - merges: &[crate::model::TableMerge], + cells: &[Vec], + merges: &[crate::construction_frame::pages::workspace::doc::model::TableMerge], block_idx: usize, ) -> Vec { let mut out = Vec::new(); diff --git a/crates/apps/doc/doc-ui/src/mobile_gesture.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/mobile_gesture.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/mobile_gesture.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/mobile_gesture.rs diff --git a/crates/apps/doc/doc-ui/src/lib.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/mod.rs similarity index 76% rename from crates/apps/doc/doc-ui/src/lib.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/mod.rs index 9b71529..bd2cb6e 100644 --- a/crates/apps/doc/doc-ui/src/lib.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/mod.rs @@ -3,8 +3,6 @@ pub mod advanced_json; pub mod collaboration; pub mod crdt_bridge; pub mod crdt_widget; -pub mod dashboard; -pub mod doc_import; pub mod editing; pub mod layout; pub mod mobile_gesture; @@ -23,14 +21,10 @@ pub use advanced_json::{ pub use collaboration::{CollaborationSession, DocumentOperation, Presence}; pub use crdt_bridge::CrdtProjectionBridge; pub use crdt_widget::CrdtDocEditor; -pub use dashboard::{DocDashboard, DocDashboardAction}; -pub use doc_import::{import_document_from_path, ImportedDoc}; pub use editing::RemoteApplyResult; pub use mobile_gesture::{MobileGestureAction, MobileGestureRouter, MobileGestureState}; pub use model::{CellContent, DocAlign, DocBlock, DocCursor, Document, Selection, StyleSpan}; -pub use persistence::{ - load_saved_doc_state, save_doc_state, save_doc_state_as, DocEntry, -}; +pub use persistence::{load_saved_doc_state, save_doc_state, save_doc_state_as}; pub use plugins::{BlockPluginDescriptor, PluginRegistry}; pub use widgets::{CrdtDocWorkspace, DocEditor, DocWorkspace, InlineStyle, InteractionMode}; @@ -80,78 +74,27 @@ script_mod! { draw_divider_line +: { draw_depth: 0.15 color: #xdee2e6 } } - mod.widgets.DocDashboard = #(DocDashboard::register_widget(vm)) { - width: Fill, height: Fill, flow: Down - draw_bg +: { color: #x1a1a2e } - - dashboard_header := View { - width: Fill, height: Fit, flow: Right {wrap: true} - padding: Inset{left: 14.0, right: 14.0, top: 8.0, bottom: 8.0}, spacing: 8.0, align: Align{y: 0.5} - draw_bg +: { color: #x242438 } - - dashboard_title := Label { - text: "Documents", - draw_text +: { color: #xd8d8e8, text_style: theme.font_bold { font_size: 18.0 } } - } - spacer := View { width: Fill } - refresh_btn := Button { - text: "Refresh", - width: 80.0, height: 32.0 - draw_bg +: { color: #x313244 } - draw_text +: { color: #xd8d8e8, text_style +: { font_size: 11.0 } } - } - new_document_btn := Button { - text: "+ New", - width: 80.0, height: 32.0 - draw_bg +: { color: #x238636 } - draw_text +: { color: #xffffff, text_style +: { font_size: 11.0 } } - } - import_doc_btn := Button { - text: "Import Doc", - width: 100.0, height: 32.0 - draw_bg +: { color: #x2a5a8a } - draw_text +: { color: #xffffff, text_style +: { font_size: 11.0 } } - } - } - - cards_container := View { - width: Fill, height: Fill - draw_bg +: { color: #x1a1a2e } - } - - back_btn := Button { - visible: false - width: 0, height: 0 - } - - // Manual draw resources for document cards - draw_card_bg +: { draw_depth: 0.1 } - draw_card_hover_bg +: { draw_depth: 0.2 } - draw_card_text +: { draw_depth: 0.3 color: #xd8d8e8 text_style: theme.font_bold { font_size: 14.0 } } - draw_card_preview +: { draw_depth: 0.3 color: #x8a8aa5 text_style: theme.font_regular { font_size: 11.0 } } - card_normal_color: #x2a2a40 - card_hover_color: #x3a3a5a - card_text_color: #xd8d8e8 - card_preview_color: #x8a8aa5 - } - mod.widgets.CrdtDocWorkspace = #(CrdtDocWorkspace::register_widget(vm)) { width: Fill, height: Fill, flow: Down draw_bg +: { color: #x181825 } crdt_toolbar := View { - width: Fill, height: Fit, flow: Right {wrap: true} - padding: Inset{left: 8.0, right: 8.0, top: 6.0, bottom: 6.0}, spacing: 6.0, align: Align{y: 0.5} + width: Fill, height: 48.0, flow: Right + padding: Inset{left: 12.0, right: 12.0, top: 6.0, bottom: 6.0}, spacing: 6.0, align: Align{y: 0.5} draw_bg +: { color: #x11111b } open_file_btn := Button { text: "Open", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xcdd6f4, text_style +: { font_size: 11.0 } } } save_btn := Button { text: "Save", width: 44.0, draw_bg +: { color: #x238636 }, draw_text +: { color: #xffffff, text_style +: { font_size: 11.0 } } } save_as_btn := Button { text: "SaveAs", width: 54.0, draw_bg +: { color: #x1f6feb }, draw_text +: { color: #xffffff, text_style +: { font_size: 11.0 } } } - // Edit/Done IME toggle. Kept as a plain button (not wrapped in - // a width-adaptive view) so it always renders on device; a - // Desktop/Mobile AdaptiveView would drop it in desktop-tagged - // test windows. - edit_mode_btn := Button { text: "Edit", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xcdd6f4, text_style +: { font_size: 11.0 } } } + // Mobile-only Edit/Done IME toggle (legacy parity): desktop + // renders an empty variant and keeps full editing by default. + mode_controls := AdaptiveView { + width: Fit, height: Fit, retain_unused_variants: true + Desktop := View { width: 0.0, height: 0.0 } + Mobile := View { + edit_mode_btn := Button { text: "Edit", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xcdd6f4, text_style +: { font_size: 11.0 } } } + } + } separator0 := View { width: 1.0, height: 26.0, draw_bg +: { color: #x45475a } } undo_button := Button { text: "Undo", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xcdd6f4, text_style +: { font_size: 11.0 } } } @@ -194,13 +137,6 @@ script_mod! { crdt_status_spacer := View { width: Fill } status_right := Label { text: "Makepad Native CRDT Doc", draw_text +: { color: #x6c7086, text_style +: { font_size: 10.0 } } } } - - // -- Dashboard (file-list overlay, shown on first launch) -- - // Hidden while the editor is active; the workspace toggles - // `visible` on this widget based on `show_dashboard`. - dashboard := mod.widgets.DocDashboard { - width: Fill, height: Fill, visible: true - } } mod.widgets.DocWorkspace = #(DocWorkspace::register_widget(vm)) { @@ -208,8 +144,8 @@ script_mod! { draw_bg +: { color: #x181825 } toolbar := View { - width: Fill, height: Fit, flow: Right {wrap: true} - padding: Inset{left: 8.0, right: 8.0, top: 6.0, bottom: 6.0}, spacing: 6.0, align: Align{y: 0.5} + width: Fill, height: 48.0, flow: Right + padding: Inset{left: 12.0, right: 12.0, top: 6.0, bottom: 6.0}, spacing: 6.0, align: Align{y: 0.5} draw_bg +: { color: #x11111b } open_file_btn := Button { text: "Open", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xcdd6f4, text_style +: { font_size: 11.0 } } } @@ -262,13 +198,6 @@ script_mod! { spacer := View { width: Fill } status_right := Label { text: "Makepad Native Doc v2.1", draw_text +: { color: #x6c7086, text_style +: { font_size: 10.0 } } } } - - // -- Dashboard (file-list overlay, shown on first launch) -- - // Hidden while the editor is active; the workspace toggles - // `visible` on this widget based on `show_dashboard`. - dashboard := mod.widgets.DocDashboard { - width: Fill, height: Fill, visible: true - } } } diff --git a/crates/apps/doc/doc-ui/src/model/advanced.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/advanced.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/model/advanced.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/advanced.rs diff --git a/crates/apps/doc/doc-ui/src/model/block.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/block.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/model/block.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/block.rs diff --git a/crates/apps/doc/doc-ui/src/model/crdt.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/crdt.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/model/crdt.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/crdt.rs diff --git a/crates/apps/doc/doc-ui/src/model/crdt_advanced.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/crdt_advanced.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/model/crdt_advanced.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/crdt_advanced.rs diff --git a/crates/apps/doc/doc-ui/src/model/crdt_table.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/crdt_table.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/model/crdt_table.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/crdt_table.rs diff --git a/crates/apps/doc/doc-ui/src/model/cursor.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/cursor.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/model/cursor.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/cursor.rs diff --git a/crates/apps/doc/doc-ui/src/model/document.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/document.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/model/document.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/document.rs diff --git a/crates/apps/doc/doc-ui/src/model/mod.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/mod.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/model/mod.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/mod.rs diff --git a/crates/apps/doc/doc-ui/src/model/selection.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/selection.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/model/selection.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/selection.rs diff --git a/crates/apps/doc/doc-ui/src/model/session.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/session.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/model/session.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/session.rs diff --git a/crates/apps/doc/doc-ui/src/model/span.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/span.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/model/span.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/span.rs diff --git a/crates/apps/doc/doc-ui/src/model/style.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/style.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/model/style.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/style.rs diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/persistence.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/persistence.rs new file mode 100644 index 0000000..ec72b39 --- /dev/null +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/persistence.rs @@ -0,0 +1,74 @@ +use std::fs; +use std::path::PathBuf; + +const GENERATED_DIR: &str = "generated"; +pub(crate) const GENERATED_DOC_FILE: &str = "current.doc.json"; +pub(crate) const MAX_UNDO_LEVELS: usize = 100; + +/// Root directory for doc state written at runtime. +/// +/// This used to be `env!("CARGO_MANIFEST_DIR")`, which bakes the **build +/// machine's** absolute source path into the shipped binary. On an Android +/// or iOS install that path does not exist, so the workspace's Open/Save +/// buttons silently did nothing (the CRDT editor additionally had no demo +/// fallback, so a fresh install booted to an empty document); and on a +/// developer machine the app wrote into its own source tree. +/// +/// `app_data_dir()` is the convention the rest of this crate already uses +/// (see `cad_store::cad_projects_dir` / `cad_persistence::cad_data_dir`). +/// Reads keep a one-way compatibility fallback to the old source-tree +/// location so existing developer saves are honored once; new saves only +/// ever go to the app data dir. +pub fn doc_store_dir() -> PathBuf { + crate::dir::app_data_dir().join("nigig_build_store") +} + +/// Directory holding the runtime-saved document (`nigig_build_store/generated`). +pub fn doc_generated_dir_path() -> PathBuf { + doc_store_dir().join(GENERATED_DIR) +} + +/// The legacy save location inside the source tree (development checkouts +/// only): read as a fallback, never written. +fn dev_manifest_generated_dir_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_DIR) +} + +/// Load the last-saved document. Prefers the runtime store; falls back to +/// the legacy source-tree file for unreplicated developer saves. Returns +/// `None` when nothing exists or the file is empty. Split from the two +/// directory parameters so the migration logic is unit-testable against +/// temp dirs. +pub fn load_saved_doc_state() -> Option { + load_saved_doc_state_with(doc_generated_dir_path(), dev_manifest_generated_dir_path()) +} + +pub(crate) fn load_saved_doc_state_with( + store_dir: PathBuf, + manifest_dir: PathBuf, +) -> Option { + let read = |dir: &PathBuf| { + fs::read_to_string(dir.join(GENERATED_DOC_FILE)) + .ok() + .filter(|source| !source.trim().is_empty()) + }; + read(&store_dir).or_else(|| read(&manifest_dir)) +} + +/// Save the current document to the runtime store (never the source tree). +pub fn save_doc_state(data: &str) -> Result<(), String> { + save_doc_state_to(doc_generated_dir_path(), GENERATED_DOC_FILE, data) +} + +/// Save a copy under a caller-chosen filename in the runtime store. +pub fn save_doc_state_as(filename: &str, data: &str) -> Result<(), String> { + save_doc_state_to(doc_generated_dir_path(), filename, data) +} + +pub(crate) fn save_doc_state_to(dir: PathBuf, filename: &str, data: &str) -> Result<(), String> { + fs::create_dir_all(&dir) + .map_err(|err| format!("could not create generated directory: {err}"))?; + fs::write(dir.join(filename), data) + .map_err(|err| format!("could not save doc state: {err}"))?; + Ok(()) +} diff --git a/crates/apps/doc/doc-ui/src/plugins/mod.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/plugins/mod.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/plugins/mod.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/plugins/mod.rs diff --git a/crates/apps/doc/doc-ui/src/projection_layout.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_layout.rs similarity index 99% rename from crates/apps/doc/doc-ui/src/projection_layout.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_layout.rs index 43dc75e..fcd1045 100644 --- a/crates/apps/doc/doc-ui/src/projection_layout.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_layout.rs @@ -580,7 +580,7 @@ pub const TABLE_CELL_TEXT_INSET: f64 = 6.0; /// use (their single-line formulas keep the historical 18px band). pub const TABLE_CELL_TEXT_LINE_HEIGHT: f64 = 18.0; -use crate::projection_session::{ +use crate::construction_frame::pages::workspace::doc::projection_session::{ TableCellCursor, TableCellSelection, }; diff --git a/crates/apps/doc/doc-ui/src/projection_renderer.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_renderer.rs similarity index 99% rename from crates/apps/doc/doc-ui/src/projection_renderer.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_renderer.rs index 5e77eb3..725b90c 100644 --- a/crates/apps/doc/doc-ui/src/projection_renderer.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_renderer.rs @@ -1,4 +1,4 @@ -use crate::projection_layout::{ +use crate::construction_frame::pages::workspace::doc::projection_layout::{ cell_text_line_count, ProjectionLayoutTree, LAYOUT_MARGIN, TABLE_CELL_TEXT_INSET, TABLE_CELL_TEXT_LINE_HEIGHT, TEXT_CHAR_ADVANCE, }; diff --git a/crates/apps/doc/doc-ui/src/projection_session.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_session.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/projection_session.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/projection_session.rs diff --git a/crates/apps/doc/doc-ui/src/render/mod.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/render/mod.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/render/mod.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/render/mod.rs diff --git a/crates/apps/doc/doc-ui/src/render/renderer.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/render/renderer.rs similarity index 98% rename from crates/apps/doc/doc-ui/src/render/renderer.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/render/renderer.rs index 5b132ed..7999b63 100644 --- a/crates/apps/doc/doc-ui/src/render/renderer.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/render/renderer.rs @@ -1,7 +1,7 @@ -use crate::layout::{ +use crate::construction_frame::pages::workspace::doc::layout::{ AdvancedLayoutBlock, LayoutPage, ParagraphFragment, TableRenderCell, }; -use crate::model::{DocAlign, StyleSpan}; +use crate::construction_frame::pages::workspace::doc::model::{DocAlign, StyleSpan}; use makepad_widgets::*; /// GPU-facing rendering boundary. It deliberately receives layout output and diff --git a/crates/apps/doc/doc-ui/src/render/resources.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/render/resources.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/render/resources.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/render/resources.rs diff --git a/crates/apps/doc/doc-ui/src/tests.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs similarity index 98% rename from crates/apps/doc/doc-ui/src/tests.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs index b4ae9df..0ba2dd8 100644 --- a/crates/apps/doc/doc-ui/src/tests.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs @@ -521,49 +521,6 @@ fn runtime_cell_return_inserts_row_below_and_moves_caret_into_it() { let _ = row; } -#[test] -fn runtime_insert_table_seeds_default_grid_and_parks_caret_in_first_cell() { - // Regression for the device bug where a bare table block (no rows / - // columns / cells) rendered at zero size and could not be typed into. - // A clean one-paragraph engine mirrors the empty-doc scenario on - // device better than table_editor_engine (which already carries a - // table whose rows would shadow the seeded grid's ordering). - let mut engine = CrdtController::default(); - engine.insert_block("local", None, "paragraph").unwrap(); - let (mut cx, mut editor) = crdt_editor_with_engine(engine); - - assert!(editor.insert_table(&mut cx), "insert_table should succeed"); - - let projection = &editor.engine().projection; - let table_id = editor - .session - .cell_cursor - .as_ref() - .map(|c| format!("{}:{}", c.table.actor, c.table.counter)) - .expect("caret parked in a table cell"); - let projected = &projection.tables[&table_id]; - assert_eq!(projected.rows.len(), 2, "default grid has 2 rows"); - assert_eq!(projected.columns.len(), 2, "default grid has 2 columns"); - - let cursor = editor.session.cell_cursor.clone().expect("cell caret"); - let cursor_row = format!("{}:{}", cursor.row.actor, cursor.row.counter); - let cursor_col = format!("{}:{}", cursor.column.actor, cursor.column.counter); - assert_eq!( - cursor_row, projected.rows[0], - "caret parked in the visual first row" - ); - assert_eq!( - cursor_col, projected.columns[0], - "caret parked in the visual first column" - ); - assert_eq!(cursor.offset, 0); - assert_eq!( - table_cell_text(projection, &cursor), - "", - "seeded cell starts empty" - ); -} - // == CRDT-native cell range selection and merge/split ====================== use super::projection_layout::{ @@ -1647,7 +1604,6 @@ fn runtime_mouse_down(abs: DVec2, shift: bool) -> Event { fn runtime_mouse_move(abs: DVec2) -> Event { Event::MouseMove(MouseMoveEvent { abs, - lock_delta: DVec2 { x: 0.0, y: 0.0 }, window_id: WindowId(0, 0), modifiers: KeyModifiers::default(), time: 0.0, diff --git a/crates/apps/doc/doc-ui/src/tests_pure.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests_pure.rs similarity index 99% rename from crates/apps/doc/doc-ui/src/tests_pure.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests_pure.rs index cb11add..903325a 100644 --- a/crates/apps/doc/doc-ui/src/tests_pure.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests_pure.rs @@ -213,7 +213,7 @@ fn crdt_native_vertical_slice() { let block = engine.insert_block("test", None, "paragraph").unwrap(); engine.insert_text("test", block.clone(), None, "hi"); let layout = - crate::projection_layout::layout_projection( + crate::construction_frame::pages::workspace::doc::projection_layout::layout_projection( &engine.projection, ); assert_eq!(engine.projection.blocks[0].text, "hi"); diff --git a/crates/apps/doc/doc-ui/src/widgets/doc_widget.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/widgets/doc_widget.rs similarity index 99% rename from crates/apps/doc/doc-ui/src/widgets/doc_widget.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/widgets/doc_widget.rs index e2d9481..6522084 100644 --- a/crates/apps/doc/doc-ui/src/widgets/doc_widget.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/widgets/doc_widget.rs @@ -1,13 +1,13 @@ -use crate::editing::{Command, DocumentController}; -use crate::layout::{ +use crate::construction_frame::pages::workspace::doc::editing::{Command, DocumentController}; +use crate::construction_frame::pages::workspace::doc::layout::{ build_table_render_cells, layout_divider, layout_image, layout_pages, layout_paragraph, layout_table, AdvancedLayout, BlockLayoutFragment, CachedBlockLayout, GlyphHit, LayoutEngine, PageMetrics, ParagraphLayoutRequest, TableLayoutRequest, TextMeasureKey, }; -use crate::model::*; -use crate::persistence::load_saved_doc_state; -use crate::render::DocumentRenderer; -use crate::{ +use crate::construction_frame::pages::workspace::doc::model::*; +use crate::construction_frame::pages::workspace::doc::persistence::load_saved_doc_state; +use crate::construction_frame::pages::workspace::doc::render::DocumentRenderer; +use crate::construction_frame::pages::workspace::doc::{ CrdtProjectionBridge, MobileGestureAction, MobileGestureRouter, }; use makepad_widgets::makepad_platform::event::{TouchState, TouchUpdateEvent}; @@ -569,7 +569,7 @@ impl Widget for DocEditor { .enumerate() .map(|(index, page_rect)| { let page_y = page_rect.pos.y; - crate::layout::LayoutPage { + crate::construction_frame::pages::workspace::doc::layout::LayoutPage { index, rect: Rect { pos: DVec2 { diff --git a/crates/apps/doc/doc-ui/src/widgets/embedded.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/widgets/embedded.rs similarity index 90% rename from crates/apps/doc/doc-ui/src/widgets/embedded.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/widgets/embedded.rs index 2201846..eec56cf 100644 --- a/crates/apps/doc/doc-ui/src/widgets/embedded.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/widgets/embedded.rs @@ -1,4 +1,4 @@ -use crate::model::EmbeddedWidgetNode; +use crate::construction_frame::pages::workspace::doc::model::EmbeddedWidgetNode; use std::collections::HashMap; /// Application-side registry for document embedded-widget descriptors. diff --git a/crates/apps/doc/doc-ui/src/widgets/mod.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/widgets/mod.rs similarity index 100% rename from crates/apps/doc/doc-ui/src/widgets/mod.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/widgets/mod.rs diff --git a/crates/apps/doc/doc-ui/src/widgets/workspace.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/widgets/workspace.rs similarity index 61% rename from crates/apps/doc/doc-ui/src/widgets/workspace.rs rename to crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/widgets/workspace.rs index 9fc95f2..dae5078 100644 --- a/crates/apps/doc/doc-ui/src/widgets/workspace.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/widgets/workspace.rs @@ -1,63 +1,27 @@ use super::{DocEditor, InlineStyle, InteractionMode}; -use crate::crdt_widget::CrdtDocEditor; -use crate::editing::Command; -use crate::model::*; -use crate::persistence::{ +use crate::construction_frame::pages::workspace::doc::crdt_widget::CrdtDocEditor; +use crate::construction_frame::pages::workspace::doc::editing::Command; +use crate::construction_frame::pages::workspace::doc::model::*; +use crate::construction_frame::pages::workspace::doc::persistence::{ load_saved_doc_state, save_doc_state, save_doc_state_as, }; -use crate::projection_layout::projected_stats; +use crate::construction_frame::pages::workspace::doc::projection_layout::projected_stats; use makepad_widgets::*; #[derive(Script, ScriptHook, Widget)] pub struct DocWorkspace { #[deref] view: View, - /// Whether the dashboard (saved-doc list) is shown instead of the - /// editor. Starts on the dashboard; transitions to the editor on - /// New/Open. - #[rust(true)] - show_dashboard: bool, } impl Widget for DocWorkspace { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.view.handle_event(cx, event, scope); - // The robius file picker's completion callback runs off the UI - // thread and parks its result; `drain_doc_import` lands it here. - if matches!(event, Event::Signal) { - self.drain_doc_import(cx); - } if let Event::Actions(actions) = event { self.handle_actions(cx, actions, scope); - self.handle_dashboard_actions(cx); } } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - if self.show_dashboard { - if let Some(mut dashboard) = self - .view - .widget(cx, ids!(dashboard)) - .borrow_mut::() - { - dashboard.set_dash_visible(cx, true); - dashboard.refresh_and_redraw(cx); - } - } else if let Some(mut dashboard) = self - .view - .widget(cx, ids!(dashboard)) - .borrow_mut::() - { - dashboard.set_dash_visible(cx, false); - } - if let Some(mut v) = self.view.widget(cx, ids!(toolbar)).borrow_mut::() { - v.set_visible(cx, !self.show_dashboard); - } - if let Some(mut v) = self.view.widget(cx, ids!(body_scroll)).borrow_mut::() { - v.set_visible(cx, !self.show_dashboard); - } - if let Some(mut v) = self.view.widget(cx, ids!(status_bar)).borrow_mut::() { - v.set_visible(cx, !self.show_dashboard); - } self.view.draw_walk(cx, scope, walk) } } @@ -348,188 +312,20 @@ impl WidgetMatchEvent for DocWorkspace { } } -impl DocWorkspace { - /// Check the dashboard widget for pending actions (new document, - /// open file, back to dashboard, import) and dispatch them. - fn handle_dashboard_actions(&mut self, cx: &mut Cx) { - let pending_action: Option = { - let widget_ref = self.view.widget(cx, ids!(dashboard)); - let Some(mut dashboard) = - widget_ref.borrow_mut::() - else { - return; - }; - dashboard.action.take() - }; - - let Some(action) = pending_action else { return }; - - match action { - crate::dashboard::DocDashboardAction::NewDocument => { - if let Some(mut editor) = self.widget(cx, ids!(editor)).borrow_mut::() { - if let Ok(wire) = crate::doc_import::blank_document_wire() { - editor.deserialize(&wire); - editor.redraw(cx); - self.label(cx, ids!(status_left)) - .set_text(cx, "New blank document"); - } - } - self.show_dashboard = false; - self.view.redraw(cx); - } - crate::dashboard::DocDashboardAction::OpenFile(filename) => { - self.open_saved_document(cx, &filename); - } - crate::dashboard::DocDashboardAction::BackToDashboard => { - self.show_dashboard = true; - self.view.redraw(cx); - } - crate::dashboard::DocDashboardAction::ImportDocument => { - self.pick_document(cx); - } - } - } - - /// Open a saved `.doc.json` from the generated directory. - fn open_saved_document(&mut self, cx: &mut Cx, filename: &str) { - let path = crate::persistence::doc_generated_dir_path().join(filename); - match crate::doc_import::import_document_from_path(&path) { - Ok(imported) => { - if let Some(mut editor) = self.widget(cx, ids!(editor)).borrow_mut::() { - editor.deserialize(&imported.wire); - editor.redraw(cx); - } - self.show_dashboard = false; - self.label(cx, ids!(status_left)) - .set_text(cx, &format!("Opened {}", imported.title)); - self.view.redraw(cx); - } - Err(e) => { - self.label(cx, ids!(status_left)) - .set_text(cx, &format!("Could not open {filename}: {e}")); - } - } - } - - /// Open the platform file dialog for an external document. - fn pick_document(&mut self, cx: &mut Cx) { - if let Err(e) = crate::doc_import::open_document_dialog() { - self.label(cx, ids!(status_left)) - .set_text(cx, &format!("Could not open the file picker: {e}")); - } - } - - /// Apply a parked file-picker outcome, if any. Called on `Event::Signal`. - fn drain_doc_import(&mut self, cx: &mut Cx) { - let Some(outcome) = crate::doc_import::take_pending_import() else { - return; - }; - match outcome { - crate::doc_import::DocImportOutcome::Picked(path) => { - self.finish_doc_import(cx, &path); - } - crate::doc_import::DocImportOutcome::Failed(message) => { - self.label(cx, ids!(status_left)).set_text(cx, &message); - } - } - } - - /// Finish a document import from a locally readable path. - fn finish_doc_import(&mut self, cx: &mut Cx, path: &std::path::Path) { - match crate::doc_import::import_document_from_path(path) { - Ok(imported) => { - if let Some(mut editor) = self.widget(cx, ids!(editor)).borrow_mut::() { - editor.deserialize(&imported.wire); - editor.redraw(cx); - } - self.show_dashboard = false; - crate::persistence::save_doc_state_as( - &import_save_filename(path), - &imported.wire, - ) - .ok(); - self.label(cx, ids!(status_left)) - .set_text(cx, &format!("Imported {}", imported.title)); - self.view.redraw(cx); - } - Err(e) => { - self.label(cx, ids!(status_left)) - .set_text(cx, &format!("Could not import {}: {e}", path.display())); - } - } - } -} - -/// Derive a plain `.doc.json` save name for an imported file. -fn import_save_filename(path: &std::path::Path) -> String { - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("imported") - .trim() - .replace([' ', '\t', '\n'], "_"); - let stem = if stem.is_empty() { - String::from("imported") - } else { - stem - }; - format!("{stem}.doc.json") -} - #[derive(Script, ScriptHook, Widget)] pub struct CrdtDocWorkspace { #[deref] view: View, - /// Whether the dashboard (saved-doc list) is shown instead of the - /// editor. Starts on the dashboard; transitions to the editor on - /// New/Open. - #[rust(true)] - show_dashboard: bool, } impl Widget for CrdtDocWorkspace { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.view.handle_event(cx, event, scope); - // The robius file picker's completion callback runs off the UI - // thread and parks its result; `drain_doc_import` lands it here. - if matches!(event, Event::Signal) { - self.drain_doc_import(cx); - } if let Event::Actions(actions) = event { self.handle_actions(cx, actions, scope); - self.handle_dashboard_actions(cx); } } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - if self.show_dashboard { - if let Some(mut dashboard) = self - .view - .widget(cx, ids!(dashboard)) - .borrow_mut::() - { - dashboard.set_dash_visible(cx, true); - dashboard.refresh_and_redraw(cx); - } - } else if let Some(mut dashboard) = self - .view - .widget(cx, ids!(dashboard)) - .borrow_mut::() - { - dashboard.set_dash_visible(cx, false); - } - if let Some(mut v) = self.view.widget(cx, ids!(crdt_toolbar)).borrow_mut::() { - v.set_visible(cx, !self.show_dashboard); - } - if let Some(mut v) = self.view.widget(cx, ids!(crdt_body)).borrow_mut::() { - v.set_visible(cx, !self.show_dashboard); - } - if let Some(mut v) = self - .view - .widget(cx, ids!(crdt_status_bar)) - .borrow_mut::() - { - v.set_visible(cx, !self.show_dashboard); - } self.view.draw_walk(cx, scope, walk) } } @@ -539,8 +335,6 @@ impl Widget for CrdtDocWorkspace { /// toggle stays legacy-only until the native widget owns an IME mode. impl WidgetMatchEvent for CrdtDocWorkspace { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { - // Mobile Edit/Done IME toggle (legacy parity): flips the editor's - // InteractionMode and mirrors the state on the button label. // Mobile Edit/Done IME toggle (legacy parity): flips the editor's // InteractionMode and mirrors the state on the button label. if self.button(cx, ids!(edit_mode_btn)).clicked(actions) { @@ -741,123 +535,3 @@ impl WidgetMatchEvent for CrdtDocWorkspace { } } } - -impl CrdtDocWorkspace { - /// Check the dashboard widget for pending actions (new document, - /// open file, back to dashboard, import) and dispatch them. - fn handle_dashboard_actions(&mut self, cx: &mut Cx) { - let pending_action: Option = { - let widget_ref = self.view.widget(cx, ids!(dashboard)); - let Some(mut dashboard) = - widget_ref.borrow_mut::() - else { - return; - }; - dashboard.action.take() - }; - - let Some(action) = pending_action else { return }; - - match action { - crate::dashboard::DocDashboardAction::NewDocument => { - if let Some(mut editor) = self - .widget(cx, ids!(crdt_editor)) - .borrow_mut::() - { - if let Ok(wire) = crate::doc_import::blank_document_wire() { - editor.deserialize(cx, &wire); - self.label(cx, ids!(status_left)) - .set_text(cx, "New blank document"); - } - } - self.show_dashboard = false; - self.view.redraw(cx); - } - crate::dashboard::DocDashboardAction::OpenFile(filename) => { - self.open_saved_document(cx, &filename); - } - crate::dashboard::DocDashboardAction::BackToDashboard => { - self.show_dashboard = true; - self.view.redraw(cx); - } - crate::dashboard::DocDashboardAction::ImportDocument => { - self.pick_document(cx); - } - } - } - - /// Open a saved `.doc.json` from the generated directory. - fn open_saved_document(&mut self, cx: &mut Cx, filename: &str) { - let path = crate::persistence::doc_generated_dir_path().join(filename); - match crate::doc_import::import_document_from_path(&path) { - Ok(imported) => { - if let Some(mut editor) = self - .widget(cx, ids!(crdt_editor)) - .borrow_mut::() - { - editor.deserialize(cx, &imported.wire); - editor.redraw(cx); - } - self.show_dashboard = false; - self.label(cx, ids!(status_left)) - .set_text(cx, &format!("Opened {}", imported.title)); - self.view.redraw(cx); - } - Err(e) => { - self.label(cx, ids!(status_left)) - .set_text(cx, &format!("Could not open {filename}: {e}")); - } - } - } - - /// Open the platform file dialog for an external document. - fn pick_document(&mut self, cx: &mut Cx) { - if let Err(e) = crate::doc_import::open_document_dialog() { - self.label(cx, ids!(status_left)) - .set_text(cx, &format!("Could not open the file picker: {e}")); - } - } - - /// Apply a parked file-picker outcome, if any. Called on `Event::Signal`. - fn drain_doc_import(&mut self, cx: &mut Cx) { - let Some(outcome) = crate::doc_import::take_pending_import() else { - return; - }; - match outcome { - crate::doc_import::DocImportOutcome::Picked(path) => { - self.finish_doc_import(cx, &path); - } - crate::doc_import::DocImportOutcome::Failed(message) => { - self.label(cx, ids!(status_left)).set_text(cx, &message); - } - } - } - - /// Finish a document import from a locally readable path. - fn finish_doc_import(&mut self, cx: &mut Cx, path: &std::path::Path) { - match crate::doc_import::import_document_from_path(path) { - Ok(imported) => { - if let Some(mut editor) = self - .widget(cx, ids!(crdt_editor)) - .borrow_mut::() - { - editor.deserialize(cx, &imported.wire); - editor.redraw(cx); - } - self.show_dashboard = false; - crate::persistence::save_doc_state_as( - &import_save_filename(path), - &imported.wire, - ) - .ok(); - self.label(cx, ids!(status_left)) - .set_text(cx, &format!("Imported {}", imported.title)); - self.view.redraw(cx); - } - Err(e) => { - self.label(cx, ids!(status_left)) - .set_text(cx, &format!("Could not import {}: {e}", path.display())); - } - } - } -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/mod.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/mod.rs index f21ab5f..c0a1caa 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/mod.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/mod.rs @@ -1,6 +1,7 @@ // nigig-build/src/construction_frame/pages/workspace/mod.rs pub mod cad; pub mod cost_estimator; +pub mod doc; pub mod invoice; pub mod project; pub mod project_management; @@ -25,7 +26,7 @@ pub fn script_mod(vm: &mut ScriptVm) { spreadsheet_ui::script_mod(vm); invoice::script_mod(vm); // doc::script_mod(vm); - doc_ui::script_mod(vm); + doc::widgets::script_mod(vm); project_management::script_mod(vm); solar_calculator::script_mod(vm); project::script_mod(vm); diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/project/mod.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/project/mod.rs index d7ea02a..c10e852 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/project/mod.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/project/mod.rs @@ -648,7 +648,6 @@ script_mod! { m_workspace_flip := PageFlip { width: Fill, height: Fill - lazy_init: true active_page: @cad_page // ─── CAD workspace ───────────────────────────── cad_page := View { @@ -1146,9 +1145,7 @@ impl BuildProjectsPage { id: proj.id.clone(), name: proj.name.clone(), }); - // Hand off to the CAD workspace, which lands on its - // project dashboard for the selected project. - self.open_workspace(cx, "CAD Workspace"); + self.view.redraw(cx); } return; } diff --git a/crates/apps/nigig-build/tests/ui.rs b/crates/apps/nigig-build/tests/ui.rs index def3a07..9329bf6 100644 --- a/crates/apps/nigig-build/tests/ui.rs +++ b/crates/apps/nigig-build/tests/ui.rs @@ -470,423 +470,1420 @@ fn pm_set_baseline_button_exists(app: TestApp) { app.locator(Selector::id("set_baseline_btn")).wait_visible(); } - -// ── CAD workspace tests (56 tests) ────────────────────────── -// -// Navigation: click m_workspace_cad_btn → overlay → cad_viewport. -// No project needed for CAD access. -// Mobile layout (430×860): first ~14 row0 buttons visible, export off-screen. - -/// Helper: navigate to CAD workspace from the construction screen. -fn open_cad_workspace(app: &TestApp) { - app.locator(Selector::id("m_workspace_cad_btn")) - .wait_visible() - .click(); - app.locator(Selector::id("cad_viewport")).wait_visible(); -} - -// ── 1. Navigation (5) ────────────────────────────────────── +// ── CAD workspace tests ──────────────────────────────────────── #[makepad_test] -fn cad_01_viewport_exists(app: TestApp) { - open_cad_workspace(&app); +fn cad_viewport_exists(app: TestApp) { + if !require_project_loaded() { + return; + } app.locator(Selector::id("cad_viewport")).wait_visible(); } #[makepad_test] -fn cad_02_status_label_visible(app: TestApp) { - open_cad_workspace(&app); +fn cad_tool_overlay_exists(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Tool overlay is drawn by the viewport; check for status label + app.locator(Selector::id("cad_status_label")).wait_visible(); +} + +#[makepad_test] +fn cad_tool_key_switches_tool(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Press L to switch to Line tool + app.press_key(makepad_test::KeyCode::KeyL); + // Status label should update + app.locator(Selector::id("cad_status_label")).wait_visible(); +} + +#[makepad_test] +fn cad_escape_cancels_drawing(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Press L then Escape — should return to Select + app.press_key(makepad_test::KeyCode::KeyL); + app.press_key(makepad_test::KeyCode::Escape); + app.locator(Selector::id("cad_status_label")).wait_visible(); +} + +#[makepad_test] +fn cad_toggle_ortho_polar(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // F8 toggles ortho, F9 toggles polar + app.press_key(makepad_test::KeyCode::F8); + app.press_key(makepad_test::KeyCode::F9); + app.locator(Selector::id("cad_status_label")).wait_visible(); +} + +#[makepad_test] +fn cad_dde_digit_enters_buffer(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Switch to Line tool, click to start line, type digit + app.press_key(makepad_test::KeyCode::KeyL); + app.locator(Selector::id("cad_viewport")).click(); + // Type a digit — DDE buffer should contain "5" + app.press_key(makepad_test::KeyCode::Key5); +} + +#[makepad_test] +fn cad_dde_backspace_removes_char(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Switch to Line, click, type digits, backspace + app.press_key(makepad_test::KeyCode::KeyL); + app.locator(Selector::id("cad_viewport")).click(); + app.press_key(makepad_test::KeyCode::Key5); + app.press_key(makepad_test::KeyCode::Key3); + app.press_key(makepad_test::KeyCode::Backspace); +} + +#[makepad_test] +fn cad_select_tool_click(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // S should switch to Select tool + app.press_key(makepad_test::KeyCode::KeyS); + app.locator(Selector::id("cad_status_label")).wait_visible(); +} + +#[makepad_test] +fn cad_multiple_tool_switches(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Cycle through several tools via keyboard + let keys = [ + makepad_test::KeyCode::KeyL, + makepad_test::KeyCode::KeyR, + makepad_test::KeyCode::KeyC, + makepad_test::KeyCode::KeyW, + ]; + for key in keys { + app.press_key(key); + app.locator(Selector::id("cad_status_label")).wait_visible(); + } +} + +// ── Section shape tests ──────────────────────────────────────── + +#[makepad_test] +fn cad_beam_tool_shows_rect_section(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Press B to switch to Beam tool + app.press_key(makepad_test::KeyCode::KeyB); + app.locator(Selector::id("cad_status_label")).wait_visible(); +} + +#[makepad_test] +fn cad_beam_tab_cycles_section(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Switch to Beam tool (B), then Tab to cycle section + app.press_key(makepad_test::KeyCode::KeyB); + app.locator(Selector::id("cad_status_label")).wait_visible(); + app.press_key(makepad_test::KeyCode::Tab); + app.locator(Selector::id("cad_status_label")).wait_visible(); + app.press_key(makepad_test::KeyCode::Tab); + app.locator(Selector::id("cad_status_label")).wait_visible(); +} + +#[makepad_test] +fn cad_beam_section_indicator_drawn(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Switch to Beam, create a beam, then verify section indicator appears + app.press_key(makepad_test::KeyCode::KeyB); + app.locator(Selector::id("cad_viewport")).click(); + app.press_key(makepad_test::KeyCode::Escape); + // Verify viewport still renders + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_beam_ibeam_section_switch(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Switch to Beam tool, Tab once to I-Beam, Tab once to HSS, Tab back to Rect + app.press_key(makepad_test::KeyCode::KeyB); + app.press_key(makepad_test::KeyCode::Tab); + app.press_key(makepad_test::KeyCode::Tab); + app.press_key(makepad_test::KeyCode::Tab); + app.locator(Selector::id("cad_status_label")).wait_visible(); +} + +#[makepad_test] +fn cad_beam_section_ignored_when_not_beam(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Switch to Line tool — Tab should NOT cycle beam section + app.press_key(makepad_test::KeyCode::KeyL); + app.press_key(makepad_test::KeyCode::Tab); + app.locator(Selector::id("cad_status_label")).wait_visible(); +} + +// =========================================================================== +// Session 6 — Selection actions UI tests +// =========================================================================== + +#[makepad_test] +fn cad_escape_deselects(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Press Escape — should deselect all + app.press_key(makepad_test::KeyCode::Escape); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_isolate_toggle(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Click on viewport to select something, then press I to isolate + app.locator(Selector::id("cad_viewport")).click(); + app.press_key(makepad_test::KeyCode::KeyI); + // Press I again to restore + app.press_key(makepad_test::KeyCode::KeyI); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_dof_toggle_shift_d(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Select something and toggle DOF + app.locator(Selector::id("cad_viewport")).click(); + app.press_key(makepad_test::KeyCode::Shift); + app.press_key(makepad_test::KeyCode::KeyD); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_properties_panel_dof_row(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Select a part to show properties panel + app.locator(Selector::id("cad_viewport")).click(); + // Properties panel should now be visible with DOF buttons + app.locator(Selector::id("properties_panel_view")) + .wait_visible(); +} + +// =========================================================================== +// Session 7 — Export + PdfPreview UI tests +// =========================================================================== + +#[makepad_test] +fn cad_stl_export_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Click STL export button + app.locator(Selector::id("export_stl_btn")).click(); + // Status label should update with STL status app.locator(Selector::id("status_label")).wait_visible(); } #[makepad_test] -fn cad_03_title_shows_cad(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("title_label")) - .wait_visible() - .assert_text("CAD"); +fn cad_svg_export_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Click SVG export button + app.locator(Selector::id("export_svg_btn")).click(); + // Should auto-switch to preview tab after export + app.locator(Selector::id("status_label")).wait_visible(); } #[makepad_test] -fn cad_04_viewport_area_visible(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("viewport_area")).wait_visible(); +fn cad_pdf_tab_switch(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Click PDF tab to switch to preview page + app.locator(Selector::id("desktop_pdf_tab_btn")).click(); + // Preview page should be visible + app.locator(Selector::id("desktop_pdf_page")).wait_visible(); } #[makepad_test] -fn cad_05_single_viewport_layer_visible(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("single_viewport_layer")).wait_visible(); -} - -// ── 2. Tool switching via keyboard (8) ───────────────────── - -#[makepad_test] -fn cad_06_select_tool_is_default(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("status_label")) - .wait_text("Click to select parts"); +fn cad_pdf_tab_then_script_tab(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Switch to PDF preview + app.locator(Selector::id("desktop_pdf_tab_btn")).click(); + app.locator(Selector::id("desktop_pdf_page")).wait_visible(); + // Switch back to Script + app.locator(Selector::id("desktop_editor_tab_btn")).click(); + app.locator(Selector::id("desktop_script_page")) + .wait_visible(); } #[makepad_test] -fn cad_07_key_L_switches_to_line(app: TestApp) { - open_cad_workspace(&app); - app.press_key(makepad_test::KeyCode::KeyL); - app.locator(Selector::id("status_label")) - .wait_text("Click start point, click end point"); +fn cad_svg_export_shows_preview(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Click SVG export — should show SVG preview and hide placeholder + app.locator(Selector::id("export_svg_btn")).click(); + app.locator(Selector::id("desktop_svg_preview")) + .wait_visible(); +} + +// =========================================================================== +// Session 8 — Comprehensive CAD Feature UI Tests +// =========================================================================== + +#[makepad_test] +fn cad_toolbar_buttons_exist(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // All toolbar buttons should be visible + app.locator(Selector::id("select_tool_btn")).wait_visible(); + app.locator(Selector::id("line_tool_btn")).wait_visible(); + app.locator(Selector::id("rect_tool_btn")).wait_visible(); + app.locator(Selector::id("circle_tool_btn")).wait_visible(); + app.locator(Selector::id("wall_tool_btn")).wait_visible(); + app.locator(Selector::id("beam_tool_btn")).wait_visible(); + app.locator(Selector::id("column_tool_btn")).wait_visible(); + app.locator(Selector::id("add_cube_button")).wait_visible(); + app.locator(Selector::id("add_cylinder_button")) + .wait_visible(); + app.locator(Selector::id("add_sphere_button")) + .wait_visible(); + app.locator(Selector::id("add_door_button")).wait_visible(); + app.locator(Selector::id("add_window_button")) + .wait_visible(); } #[makepad_test] -fn cad_08_key_R_switches_to_rect(app: TestApp) { - open_cad_workspace(&app); - app.press_key(makepad_test::KeyCode::KeyR); - app.locator(Selector::id("status_label")) - .wait_text("Click first corner, click opposite corner"); +fn cad_toolbar_export_buttons_exist(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("export_stl_btn")).wait_visible(); + app.locator(Selector::id("export_svg_btn")).wait_visible(); + app.locator(Selector::id("export_pdf_btn")).wait_visible(); + app.locator(Selector::id("export_obj_btn")).wait_visible(); + app.locator(Selector::id("export_3d_btn")).wait_visible(); } #[makepad_test] -fn cad_09_key_C_switches_to_circle(app: TestApp) { - open_cad_workspace(&app); - app.press_key(makepad_test::KeyCode::KeyC); - app.locator(Selector::id("status_label")) - .wait_text("Click center point, click to set radius"); -} - -#[makepad_test] -fn cad_10_key_P_switches_to_polyline(app: TestApp) { - open_cad_workspace(&app); - app.press_key(makepad_test::KeyCode::KeyP); - app.locator(Selector::id("status_label")) - .wait_text("Click successive points"); -} - -#[makepad_test] -fn cad_11_key_E_switches_to_extend(app: TestApp) { - open_cad_workspace(&app); - app.press_key(makepad_test::KeyCode::KeyE); - app.locator(Selector::id("status_label")) - .wait_text("Click a line/edge, then click the direction"); -} - -#[makepad_test] -fn cad_12_key_W_switches_to_wall(app: TestApp) { - open_cad_workspace(&app); - app.press_key(makepad_test::KeyCode::KeyW); - app.locator(Selector::id("status_label")) - .wait_text("Click start, click end. Creates a wall"); -} - -#[makepad_test] -fn cad_13_key_V_switches_to_select(app: TestApp) { - open_cad_workspace(&app); - app.press_key(makepad_test::KeyCode::KeyL); - app.press_key(makepad_test::KeyCode::KeyV); - app.locator(Selector::id("status_label")) - .wait_text("Click to select parts"); -} - -// ── 3. Escape + cancel (4) ───────────────────────────────── - -#[makepad_test] -fn cad_14_escape_returns_to_select(app: TestApp) { - open_cad_workspace(&app); - app.press_key(makepad_test::KeyCode::KeyL); - app.press_key(makepad_test::KeyCode::Escape); - app.locator(Selector::id("status_label")) - .wait_text("Click to select parts"); -} - -#[makepad_test] -fn cad_15_tool_button_click_updates_status(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("line_tool_btn")).click(); - app.locator(Selector::id("status_label")) - .wait_text("Click start point, click end point"); -} - -#[makepad_test] -fn cad_16_tool_button_click_then_escape(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("circle_tool_btn")).click(); - app.locator(Selector::id("status_label")) - .wait_text("Click center point, click to set radius"); - app.press_key(makepad_test::KeyCode::Escape); - app.locator(Selector::id("status_label")) - .wait_text("Click to select parts"); -} - -#[makepad_test] -fn cad_17_multiple_tool_switches(app: TestApp) { - open_cad_workspace(&app); - app.press_key(makepad_test::KeyCode::KeyL); - app.locator(Selector::id("status_label")) - .wait_text("Click start point, click end point"); - app.press_key(makepad_test::KeyCode::KeyR); - app.locator(Selector::id("status_label")) - .wait_text("Click first corner, click opposite corner"); - app.press_key(makepad_test::KeyCode::KeyC); - app.locator(Selector::id("status_label")) - .wait_text("Click center point, click to set radius"); - app.press_key(makepad_test::KeyCode::KeyV); - app.locator(Selector::id("status_label")) - .wait_text("Click to select parts"); -} - -// ── 4. View controls (6) ─────────────────────────────────── - -#[makepad_test] -fn cad_18_view_toggle_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("view_toggle_button")).wait_visible(); -} - -#[makepad_test] -fn cad_19_view_toggle_switches_to_2d(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("view_toggle_button")).click(); - app.locator(Selector::id("view_toggle_button")) - .wait_text("3D"); -} - -#[makepad_test] -fn cad_20_zoom_in_button_exists(app: TestApp) { - open_cad_workspace(&app); +fn cad_toolbar_zoom_buttons_exist(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("zoom_in_button")).wait_visible(); -} - -#[makepad_test] -fn cad_21_zoom_out_button_exists(app: TestApp) { - open_cad_workspace(&app); app.locator(Selector::id("zoom_out_button")).wait_visible(); -} - -#[makepad_test] -fn cad_22_fit_button_exists(app: TestApp) { - open_cad_workspace(&app); app.locator(Selector::id("fit_button")).wait_visible(); + app.locator(Selector::id("grow_button")).wait_visible(); + app.locator(Selector::id("shrink_button")).wait_visible(); } #[makepad_test] -fn cad_23_rotation_buttons_exist(app: TestApp) { - open_cad_workspace(&app); +fn cad_toolbar_rotation_buttons_exist(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("rot_x_button")).wait_visible(); app.locator(Selector::id("rot_y_button")).wait_visible(); app.locator(Selector::id("rot_z_button")).wait_visible(); + app.locator(Selector::id("plane_toggle_btn")).wait_visible(); + app.locator(Selector::id("rot_wp_btn")).wait_visible(); + app.locator(Selector::id("incl_plane_btn")).wait_visible(); } -// ── 5. Snap / ortho / polar (3) ──────────────────────────── - #[makepad_test] -fn cad_24_snap_toggle_button_exists(app: TestApp) { - open_cad_workspace(&app); +fn cad_toolbar_snap_ortho_polar_buttons_exist(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("snap_toggle_btn")).wait_visible(); -} - -#[makepad_test] -fn cad_25_ortho_toggle_button_exists(app: TestApp) { - open_cad_workspace(&app); app.locator(Selector::id("ortho_toggle_btn")).wait_visible(); -} - -#[makepad_test] -fn cad_26_polar_toggle_button_exists(app: TestApp) { - open_cad_workspace(&app); app.locator(Selector::id("polar_toggle_btn")).wait_visible(); -} - -// ── 6. Undo / redo (2) ───────────────────────────────────── - -#[makepad_test] -fn cad_27_undo_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("undo_button")).wait_visible(); + app.locator(Selector::id("snap_step_dropdown")) + .wait_visible(); } #[makepad_test] -fn cad_28_redo_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("redo_button")).wait_visible(); -} - -// ── 7. Add primitives (5) ────────────────────────────────── - -#[makepad_test] -fn cad_29_add_cube_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("add_cube_button")).wait_visible(); +fn cad_toolbar_grid_plane_buttons_exist(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("grid_xz_btn")).wait_visible(); + app.locator(Selector::id("grid_yz_btn")).wait_visible(); + app.locator(Selector::id("ground_op_btn")).wait_visible(); + app.locator(Selector::id("ref_plane_btn")).wait_visible(); + app.locator(Selector::id("clear_ref_btn")).wait_visible(); } #[makepad_test] -fn cad_30_add_cylinder_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("add_cylinder_button")).wait_visible(); +fn cad_toolbar_visibility_buttons_exist(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("clip_toggle_btn")).wait_visible(); + app.locator(Selector::id("constr_toggle_btn")) + .wait_visible(); + app.locator(Selector::id("constr_export_btn")) + .wait_visible(); +} + +// ── Tool selection tests ────────────────────────────────────── + +#[makepad_test] +fn cad_select_tool_default(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("select_tool_btn")) + .wait_text("Sel"); } #[makepad_test] -fn cad_31_add_sphere_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("add_sphere_button")).wait_visible(); +fn cad_tool_buttons_change_on_click(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Click line tool button + app.locator(Selector::id("line_tool_btn")).click(); + // Verify status label shows Line + app.locator(Selector::id("status_label")).wait_text("Line"); } #[makepad_test] -fn cad_32_add_wall_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("add_wall_button")).wait_visible(); +fn cad_keyboard_tool_switch(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("cad_viewport")).click(); + app.press_key(makepad_test::KeyCode::KeyR); + app.locator(Selector::id("status_label")).wait_text("Rect"); } #[makepad_test] -fn cad_33_add_slab_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("add_slab_button")).wait_visible(); +fn cad_tool_key_switches_via_button_text(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Press C for Circle + app.locator(Selector::id("cad_viewport")).click(); + app.press_key(makepad_test::KeyCode::KeyC); + // Verify button text changed to "Cir" + app.locator(Selector::id("circle_tool_btn")) + .wait_text("Cir"); } -// ── 8. Tool buttons (6) ──────────────────────────────────── +// ── Drawing creation tests ───────────────────────────────────── #[makepad_test] -fn cad_34_beam_tool_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("beam_tool_btn")).wait_visible(); +fn cad_draw_rect_via_toolbar_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("rect_tool_btn")).click(); + // Status should show Rect + app.locator(Selector::id("status_label")).wait_text("Rect"); + // Click viewport to start drawing + app.locator(Selector::id("cad_viewport")).click(); + // Click again to finish rect + app.locator(Selector::id("cad_viewport")).click(); + // Viewport should still be visible (drawing complete) + app.locator(Selector::id("cad_viewport")).wait_visible(); } #[makepad_test] -fn cad_35_arc_tool_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("arc_tool_btn")).wait_visible(); +fn cad_draw_circle_with_radius(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("circle_tool_btn")).click(); + app.locator(Selector::id("status_label")) + .wait_text("Circle"); + // Start circle + app.locator(Selector::id("cad_viewport")).click(); + // Type radius value + Enter (DDE) + app.press_key(makepad_test::KeyCode::Key5); + app.press_key(makepad_test::KeyCode::ReturnKey); } #[makepad_test] -fn cad_36_polygon_tool_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("polygon_tool_btn")).wait_visible(); +fn cad_draw_wall_segment(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("wall_tool_btn")).click(); + app.locator(Selector::id("status_label")).wait_text("Wall"); + app.locator(Selector::id("cad_viewport")).click(); + app.locator(Selector::id("cad_viewport")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); } #[makepad_test] -fn cad_37_extend_tool_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("extend_tool_btn")).wait_visible(); +fn cad_draw_column_with_radius(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("column_tool_btn")).click(); + app.locator(Selector::id("status_label")) + .wait_text("Column"); + app.locator(Selector::id("cad_viewport")).click(); + app.press_key(makepad_test::KeyCode::Key3); + app.press_key(makepad_test::KeyCode::ReturnKey); } #[makepad_test] -fn cad_38_chamfer_tool_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("chamfer_tool_btn")).wait_visible(); +fn cad_draw_beam_with_ibeam_section(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("beam_tool_btn")).click(); + app.locator(Selector::id("status_label")).wait_text("Beam"); + // Tab to cycle section types: Rect -> I-Beam -> HSS + app.press_key(makepad_test::KeyCode::Tab); + // Status label should still be Beam + app.locator(Selector::id("status_label")).wait_text("Beam"); + // Draw the beam + app.locator(Selector::id("cad_viewport")).click(); + app.locator(Selector::id("cad_viewport")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +// ── Undo/Redo tests ──────────────────────────────────────────── + +#[makepad_test] +fn cad_undo_redo_roundtrip(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Draw something + app.locator(Selector::id("rect_tool_btn")).click(); + app.locator(Selector::id("cad_viewport")).click(); + app.locator(Selector::id("cad_viewport")).click(); + // Undo + app.locator(Selector::id("undo_button")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Redo + app.locator(Selector::id("redo_button")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +// ── Selection and deletion tests ─────────────────────────────── + +#[makepad_test] +fn cad_delete_tool_removes_parts(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Draw a rect first + app.locator(Selector::id("rect_tool_btn")).click(); + app.locator(Selector::id("cad_viewport")).click(); + app.locator(Selector::id("cad_viewport")).click(); + // Switch to delete tool + app.locator(Selector::id("delete_tool_btn")).click(); + app.locator(Selector::id("status_label")) + .wait_text("Delete"); + // Select and delete + app.locator(Selector::id("cad_viewport")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); } #[makepad_test] -fn cad_39_delete_tool_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("delete_tool_btn")).wait_visible(); +fn cad_escape_cancels_active_tool(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("line_tool_btn")).click(); + app.locator(Selector::id("status_label")).wait_text("Line"); + // Cancel with Escape + app.press_key(makepad_test::KeyCode::Escape); + // Should return to Select + app.locator(Selector::id("status_label")) + .wait_text("Select"); } -// ── 9. Properties / render mode (2) ──────────────────────── +// ── View manipulation tests ──────────────────────────────────── #[makepad_test] -fn cad_40_render_mode_dropdown_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("render_mode_dropdown")).wait_visible(); +fn cad_plane_toggle_cycles_workplanes(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Initially the plane button should show "XY Plan" + app.locator(Selector::id("plane_toggle_btn")) + .wait_text("XY Plan"); + // Toggle to XZ + app.locator(Selector::id("plane_toggle_btn")).click(); + app.locator(Selector::id("plane_toggle_btn")) + .wait_text("XZ Front"); + // Toggle to YZ + app.locator(Selector::id("plane_toggle_btn")).click(); + app.locator(Selector::id("plane_toggle_btn")) + .wait_text("YZ Side"); + // Toggle back to XY + app.locator(Selector::id("plane_toggle_btn")).click(); + app.locator(Selector::id("plane_toggle_btn")) + .wait_text("XY Plan"); } #[makepad_test] -fn cad_41_view_toggle_button_clickable(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("view_toggle_button")).click(); - app.locator(Selector::id("view_toggle_button")).wait_visible(); -} - -// ── 10. Editor tabs (4) ──────────────────────────────────── - -#[makepad_test] -fn cad_42_editor_tab_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("mobile_editor_tab_btn")).wait_visible(); +fn cad_workplane_rotation_cycle(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Rot WP cycles: 0 -> 15 -> 30 -> 45 -> 90 -> -45 -> 0 + app.locator(Selector::id("rot_wp_btn")).click(); + app.locator(Selector::id("rot_wp_btn")) + .wait_text("XY Plan 15°"); + app.locator(Selector::id("rot_wp_btn")).click(); + app.locator(Selector::id("rot_wp_btn")) + .wait_text("XY Plan 30°"); + app.locator(Selector::id("rot_wp_btn")).click(); + app.locator(Selector::id("rot_wp_btn")) + .wait_text("XY Plan 45°"); + app.locator(Selector::id("rot_wp_btn")).click(); + app.locator(Selector::id("rot_wp_btn")) + .wait_text("XY Plan 90°"); + app.locator(Selector::id("rot_wp_btn")).click(); + app.locator(Selector::id("rot_wp_btn")) + .wait_text("XY Plan -45°"); + app.locator(Selector::id("rot_wp_btn")).click(); + app.locator(Selector::id("rot_wp_btn")).wait_text("XY Plan"); } #[makepad_test] -fn cad_43_cost_tab_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("mobile_cost_tab_btn")).wait_visible(); +fn cad_zoom_in_button_works(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("zoom_in_button")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); } #[makepad_test] -fn cad_44_editor_flip_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("mobile_editor_flip")).wait_visible(); +fn cad_zoom_out_button_works(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("zoom_out_button")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); } #[makepad_test] -fn cad_45_bottom_overlay_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("mobile_bottom_overlay")).wait_visible(); -} - -// ── 11. AI panel (3) ─────────────────────────────────────── - -#[makepad_test] -fn cad_46_ai_pane_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("ai_pane")).wait_visible(); +fn cad_fit_button_works(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("fit_button")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); } #[makepad_test] -fn cad_47_backend_dropdown_exists(app: TestApp) { - open_cad_workspace(&app); +fn cad_rotation_buttons_work(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("rot_x_button")).click(); + app.locator(Selector::id("rot_y_button")).click(); + app.locator(Selector::id("rot_z_button")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +// ── Snap/Ortho/Polar toggle tests ────────────────────────────── + +#[makepad_test] +fn cad_snap_toggle_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("snap_toggle_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("snap_toggle_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_ortho_toggle_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("ortho_toggle_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("ortho_toggle_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_polar_toggle_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("polar_toggle_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("polar_toggle_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +// ── Grid and reference plane tests ───────────────────────────── + +#[makepad_test] +fn cad_grid_plane_buttons(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("grid_xz_btn")).click(); + app.locator(Selector::id("grid_yz_btn")).click(); + app.locator(Selector::id("ground_op_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_reference_plane_buttons(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("ref_plane_btn")).click(); + app.locator(Selector::id("clear_ref_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +// ── Visibility toggle tests ──────────────────────────────────── + +#[makepad_test] +fn cad_clip_toggle_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("clip_toggle_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("clip_toggle_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_construction_toggle_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("constr_toggle_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("constr_toggle_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +// ── Render mode and view toggle tests ────────────────────────── + +#[makepad_test] +fn cad_render_mode_dropdown_exists(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("render_mode_dropdown")) + .wait_visible(); +} + +#[makepad_test] +fn cad_view_toggle_button_exists(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("view_toggle_button")) + .wait_visible(); +} + +// ── Export tests ─────────────────────────────────────────────── + +#[makepad_test] +fn cad_stl_export_produces_file(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("export_stl_btn")).click(); + // After export, the button should still be visible (no crash) + app.locator(Selector::id("export_stl_btn")).wait_visible(); +} + +#[makepad_test] +fn cad_svg_export_switches_to_preview(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("export_svg_btn")).click(); + // SVG preview widget should become visible after export + app.locator(Selector::id("desktop_svg_preview")) + .wait_visible(); +} + +#[makepad_test] +fn cad_pdf_export_button_clickable(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("export_pdf_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_obj_export_button_clickable(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("export_obj_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_3d_viewer_export_button_clickable(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("export_3d_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_cli_export_button_clickable(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("export_cli_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +// ── PDF preview tab tests ────────────────────────────────────── + +#[makepad_test] +fn cad_pdf_tab_button_exists(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("desktop_pdf_tab_btn")) + .wait_visible(); +} + +#[makepad_test] +fn cad_pdf_tab_switches_to_preview(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Switch to PDF preview page + app.locator(Selector::id("desktop_pdf_tab_btn")).click(); + // PDF page should be visible + app.locator(Selector::id("desktop_pdf_page")).wait_visible(); +} + +#[makepad_test] +fn cad_pdf_tab_then_back_to_script(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("desktop_pdf_tab_btn")).click(); + app.locator(Selector::id("desktop_pdf_page")).wait_visible(); + // Switch back to Script + app.locator(Selector::id("desktop_editor_tab_btn")).click(); + app.locator(Selector::id("desktop_script_page")) + .wait_visible(); +} + +#[makepad_test] +fn cad_pdf_preview_placeholder_visible(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("desktop_pdf_tab_btn")).click(); + app.locator(Selector::id("desktop_preview_placeholder")) + .wait_visible(); +} + +#[makepad_test] +fn cad_pdf_tab_then_cost_tab(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("desktop_pdf_tab_btn")).click(); + app.locator(Selector::id("desktop_pdf_page")).wait_visible(); + // Switch to Cost tab + app.locator(Selector::id("desktop_cost_tab_btn")).click(); + app.locator(Selector::id("desktop_cost_page")) + .wait_visible(); +} + +// ── Code editor tests ────────────────────────────────────────── + +#[makepad_test] +fn cad_script_editor_visible(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("desktop_script_page")) + .wait_visible(); + app.locator(Selector::id("cad_editor")).wait_visible(); +} + +#[makepad_test] +fn cad_editor_tabs_exist(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("desktop_editor_tab_btn")) + .wait_visible(); + app.locator(Selector::id("desktop_cost_tab_btn")) + .wait_visible(); + app.locator(Selector::id("desktop_split_tab_btn")) + .wait_visible(); + app.locator(Selector::id("desktop_pdf_tab_btn")) + .wait_visible(); +} + +#[makepad_test] +fn cad_code_editor_has_content(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("desktop_script_page")) + .wait_visible(); + // The editor should have some initial content + app.locator(Selector::id("cad_editor")).wait_visible(); + app.locator(Selector::id("cad_editor")).click(); + let dump = app.widget_dump(); + assert!( + dump.contains("cad_editor"), + "Widget dump should contain cad_editor" + ); +} + +// ── Cost estimation screen tests ─────────────────────────────── + +#[makepad_test] +fn cad_cost_tab_button_visible(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("desktop_cost_tab_btn")) + .wait_visible(); +} + +#[makepad_test] +fn cad_cost_page_exists(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("desktop_cost_tab_btn")).click(); + app.locator(Selector::id("desktop_cost_page")) + .wait_visible(); + app.locator(Selector::id("cost_estimate_screen")) + .wait_visible(); +} + +// ── AI pane tests ────────────────────────────────────────────── + +#[makepad_test] +fn cad_ai_pane_exists(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("ai_status_label")).wait_visible(); + app.locator(Selector::id("ai_generate_button")) + .wait_visible(); + app.locator(Selector::id("ai_cancel_button")).wait_visible(); + app.locator(Selector::id("backend_dropdown")).wait_visible(); + app.locator(Selector::id("cad_prompt_input")).wait_visible(); +} + +#[makepad_test] +fn cad_ai_backend_dropdown_visible(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("backend_dropdown")).wait_visible(); } #[makepad_test] -fn cad_48_prompt_input_exists(app: TestApp) { - open_cad_workspace(&app); +fn cad_ai_prompt_input_visible(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("cad_prompt_input")).wait_visible(); } -// ── 12. Construction + grid (4) ──────────────────────────── +// ── File operations tests ────────────────────────────────────── #[makepad_test] -fn cad_49_construction_toggle_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("constr_toggle_btn")).wait_visible(); -} - -#[makepad_test] -fn cad_50_grid_xz_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("grid_xz_btn")).wait_visible(); -} - -#[makepad_test] -fn cad_51_plane_toggle_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("plane_toggle_btn")).wait_visible(); -} - -#[makepad_test] -fn cad_52_clip_toggle_button_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("clip_toggle_btn")).wait_visible(); -} - -// ── 13. Split + file (4) ─────────────────────────────────── - -#[makepad_test] -fn cad_53_split_toggle_exists(app: TestApp) { - open_cad_workspace(&app); - app.locator(Selector::id("workspace_split_toggle_btn")).wait_visible(); -} - -#[makepad_test] -fn cad_54_open_file_button_exists(app: TestApp) { - open_cad_workspace(&app); +fn cad_file_buttons_exist(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("open_file_btn")).wait_visible(); -} - -#[makepad_test] -fn cad_55_save_button_exists(app: TestApp) { - open_cad_workspace(&app); app.locator(Selector::id("save_btn")).wait_visible(); + app.locator(Selector::id("save_as_btn")).wait_visible(); +} + +// ── Splitter tests ───────────────────────────────────────────── + +#[makepad_test] +fn cad_splitter_exists(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("workspace_split_toggle_btn")) + .wait_visible(); } #[makepad_test] -fn cad_56_extrude_button_exists(app: TestApp) { - open_cad_workspace(&app); +fn cad_workspace_split_toggle(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("workspace_split_toggle_btn")) + .click(); + // Split viewport should appear + app.locator(Selector::id("split_viewport_layer")) + .wait_visible(); + // Toggle back + app.locator(Selector::id("workspace_split_toggle_btn")) + .click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +// ── Properties panel tests ─────────────────────────────────────── + +#[makepad_test] +fn cad_properties_panel_exists(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("properties_panel_view")) + .wait_visible(); +} + +// ── Status label tests ─────────────────────────────────────────── + +#[makepad_test] +fn cad_status_label_visible(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("status_label")).wait_visible(); +} + +#[makepad_test] +fn cad_status_label_shows_select_initially(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("status_label")) + .wait_text("Select"); +} + +// ── Mobile editor tests ────────────────────────────────────────── + +#[makepad_test] +fn cad_mobile_editor_tab_button_exists(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("mobile_editor_tab_btn")) + .wait_visible(); +} + +#[makepad_test] +fn cad_mobile_cost_tab_button_exists(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("mobile_cost_tab_btn")) + .wait_visible(); +} + +// ── Snap step dropdown tests ──────────────────────────────────── + +#[makepad_test] +fn cad_snap_step_dropdown_visible(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("snap_step_dropdown")) + .wait_visible(); +} + +// ── Extrude button test ────────────────────────────────────────── + +#[makepad_test] +fn cad_extrude_button_visible(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); app.locator(Selector::id("extrude_btn")).wait_visible(); } + +// ── Measure tool test ──────────────────────────────────────────── + +#[makepad_test] +fn cad_measure_tool_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("measure_tool_btn")).click(); + app.locator(Selector::id("status_label")) + .wait_text("Measure"); +} + +// ── Delete tool test ───────────────────────────────────────────── + +#[makepad_test] +fn cad_delete_tool_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("delete_tool_btn")).click(); + app.locator(Selector::id("status_label")) + .wait_text("Delete"); +} + +// ── Arc tool test ──────────────────────────────────────────────── + +#[makepad_test] +fn cad_arc_tool_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("arc_tool_btn")).click(); + app.locator(Selector::id("status_label")).wait_text("Arc"); +} + +// ── Polyline tool test ─────────────────────────────────────────── + +#[makepad_test] +fn cad_polyline_tool_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("polyline_tool_btn")).click(); + app.locator(Selector::id("status_label")) + .wait_text("Polyline"); +} + +// ── Area and Quad tool tests ───────────────────────────────────── + +#[makepad_test] +fn cad_area_tool_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("area_tool_btn")).click(); + app.locator(Selector::id("status_label")).wait_text("Area"); +} + +#[makepad_test] +fn cad_quad_tool_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("quad_tool_btn")).click(); + app.locator(Selector::id("status_label")).wait_text("Quad"); +} + +// ── Polygon and TriPlane tool tests ────────────────────────────── + +#[makepad_test] +fn cad_polygon_tool_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("polygon_tool_btn")).click(); + app.locator(Selector::id("status_label")) + .wait_text("Polygon"); +} + +#[makepad_test] +fn cad_triplane_tool_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("triplane_tool_btn")).click(); + app.locator(Selector::id("status_label")) + .wait_text("TriPlane"); +} + +// ── Extend and Chamfer tool tests ──────────────────────────────── + +#[makepad_test] +fn cad_extend_tool_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("extend_tool_btn")).click(); + app.locator(Selector::id("status_label")) + .wait_text("Extend"); +} + +#[makepad_test] +fn cad_chamfer_tool_button(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("chamfer_tool_btn")).click(); + app.locator(Selector::id("status_label")) + .wait_text("Chamfer"); +} + +// ── Render mode test ───────────────────────────────────────────── + +#[makepad_test] +fn cad_render_mode_dropdown_clickable(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("render_mode_dropdown")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +// ── View toggle test ───────────────────────────────────────────── + +#[makepad_test] +fn cad_view_toggle_button_clickable(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("view_toggle_button")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +// ── Workspace title tests ──────────────────────────────────────── + +#[makepad_test] +fn cad_workspace_title_visible(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("workspace_title_label")) + .wait_visible(); +} + +// ── Status label text change test ──────────────────────────────── + +#[makepad_test] +fn cad_status_label_changes_with_tool(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + // Start on Select + app.locator(Selector::id("status_label")) + .wait_text("Select"); + // Switch to Line + app.locator(Selector::id("cad_viewport")).click(); + app.press_key(makepad_test::KeyCode::KeyL); + app.locator(Selector::id("status_label")).wait_text("Line"); + // Switch to Rect + app.press_key(makepad_test::KeyCode::KeyR); + app.locator(Selector::id("status_label")).wait_text("Rect"); + // Switch to Wall + app.press_key(makepad_test::KeyCode::KeyW); + app.locator(Selector::id("status_label")).wait_text("Wall"); +} + +// ── Construction plane export test ─────────────────────────────── + +#[makepad_test] +fn cad_construction_export_button_clickable(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("constr_export_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +// ── Toggle button visual state tests ──────────────────────────── + +#[makepad_test] +fn cad_snap_toggle_button_checkable(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + let before = app + .locator(Selector::id("snap_toggle_btn")) + .assert_enabled(true); + app.locator(Selector::id("snap_toggle_btn")).click(); + let after = app + .locator(Selector::id("snap_toggle_btn")) + .assert_enabled(true); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_ortho_toggle_button_checkable(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("ortho_toggle_btn")).click(); + app.locator(Selector::id("ortho_toggle_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_polar_toggle_button_checkable(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("polar_toggle_btn")).click(); + app.locator(Selector::id("polar_toggle_btn")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +// ── Grow/Shrink button tests ───────────────────────────────────── + +#[makepad_test] +fn cad_grow_button_clickable(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("grow_button")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +#[makepad_test] +fn cad_shrink_button_clickable(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("shrink_button")).click(); + app.locator(Selector::id("cad_viewport")).wait_visible(); +} + +// ── Attach image button test ───────────────────────────────────── + +#[makepad_test] +fn cad_attach_image_button_exists(app: TestApp) { + if !require_project_loaded() { + return; + } + app.locator(Selector::id("cad_viewport")).wait_visible(); + app.locator(Selector::id("attach_img_btn")).wait_visible(); +} diff --git a/crates/apps/nigig-chat/Cargo.toml b/crates/apps/nigig-chat/Cargo.toml index df29d12..173a6f8 100644 --- a/crates/apps/nigig-chat/Cargo.toml +++ b/crates/apps/nigig-chat/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-chat/src/main.rs b/crates/apps/nigig-chat/src/main.rs index fd0a16b..0660329 100644 --- a/crates/apps/nigig-chat/src/main.rs +++ b/crates/apps/nigig-chat/src/main.rs @@ -11,7 +11,8 @@ script_mod! { window.title: "nigig-chat" body +: { root := mod.widgets.StandaloneFeatureShell { - root_screen := mod.widgets.ChatScreen {} + root_screen := mod.widgets.ChatScreen {} + } standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav { root_nav := mod.widgets.ChatActionBar {} } diff --git a/crates/apps/nigig-delivery/Cargo.toml b/crates/apps/nigig-delivery/Cargo.toml index 6b5c24a..8907206 100644 --- a/crates/apps/nigig-delivery/Cargo.toml +++ b/crates/apps/nigig-delivery/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-delivery/src/main.rs b/crates/apps/nigig-delivery/src/main.rs index db4b823..bd6eca9 100644 --- a/crates/apps/nigig-delivery/src/main.rs +++ b/crates/apps/nigig-delivery/src/main.rs @@ -11,7 +11,8 @@ script_mod! { window.title: "nigig-delivery" body +: { root := mod.widgets.StandaloneFeatureShell { - root_screen := mod.widgets.DeliveryScreen {} + root_screen := mod.widgets.DeliveryScreen {} + } standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav { root_nav := mod.widgets.DeliveryActionBar {} } diff --git a/crates/apps/nigig-email/Cargo.toml b/crates/apps/nigig-email/Cargo.toml index d6355b2..e696d7e 100644 --- a/crates/apps/nigig-email/Cargo.toml +++ b/crates/apps/nigig-email/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } # Used by inbox.rs::format_thread_time for list-row timestamps. diff --git a/crates/apps/nigig-feed/Cargo.toml b/crates/apps/nigig-feed/Cargo.toml index 36524be..8a8ec5f 100644 --- a/crates/apps/nigig-feed/Cargo.toml +++ b/crates/apps/nigig-feed/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-feed/src/main.rs b/crates/apps/nigig-feed/src/main.rs index f11f2c2..89dd434 100644 --- a/crates/apps/nigig-feed/src/main.rs +++ b/crates/apps/nigig-feed/src/main.rs @@ -11,7 +11,8 @@ script_mod! { window.title: "nigig-feed" body +: { root := mod.widgets.StandaloneFeatureShell { - root_screen := mod.widgets.FeedScreen {} + root_screen := mod.widgets.FeedScreen {} + } standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav { root_nav := mod.widgets.FeedActionBar {} } diff --git a/crates/apps/nigig-garage/Cargo.toml b/crates/apps/nigig-garage/Cargo.toml index fc4ca81..22817e5 100644 --- a/crates/apps/nigig-garage/Cargo.toml +++ b/crates/apps/nigig-garage/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-garage/src/main.rs b/crates/apps/nigig-garage/src/main.rs index 228a777..66027c0 100644 --- a/crates/apps/nigig-garage/src/main.rs +++ b/crates/apps/nigig-garage/src/main.rs @@ -11,7 +11,8 @@ script_mod! { window.title: "nigig-garage" body +: { root := mod.widgets.StandaloneFeatureShell { - root_screen := mod.widgets.GarageScreen {} + root_screen := mod.widgets.GarageScreen {} + } standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav { root_nav := mod.widgets.GarageActionBar {} } diff --git a/crates/apps/nigig-health/Cargo.toml b/crates/apps/nigig-health/Cargo.toml index a30b775..1fd726e 100644 --- a/crates/apps/nigig-health/Cargo.toml +++ b/crates/apps/nigig-health/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-health/src/main.rs b/crates/apps/nigig-health/src/main.rs index 43e5e1b..c426371 100644 --- a/crates/apps/nigig-health/src/main.rs +++ b/crates/apps/nigig-health/src/main.rs @@ -11,7 +11,8 @@ script_mod! { window.title: "nigig-health" body +: { root := mod.widgets.StandaloneFeatureShell { - root_screen := mod.widgets.MedicalsScreen {} + root_screen := mod.widgets.MedicalsScreen {} + } standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav { root_nav := mod.widgets.HealthActionBar {} } diff --git a/crates/apps/nigig-insure/Cargo.toml b/crates/apps/nigig-insure/Cargo.toml index 83884ef..5fdb19f 100644 --- a/crates/apps/nigig-insure/Cargo.toml +++ b/crates/apps/nigig-insure/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-insure/src/main.rs b/crates/apps/nigig-insure/src/main.rs index b136c73..86bcba0 100644 --- a/crates/apps/nigig-insure/src/main.rs +++ b/crates/apps/nigig-insure/src/main.rs @@ -11,7 +11,8 @@ script_mod! { window.title: "nigig-insure" body +: { root := mod.widgets.StandaloneFeatureShell { - root_screen := mod.widgets.InsuranceScreen {} + root_screen := mod.widgets.InsuranceScreen {} + } standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav { root_nav := mod.widgets.InsureActionBar {} } diff --git a/crates/apps/nigig-marikiti/Cargo.toml b/crates/apps/nigig-marikiti/Cargo.toml index 0b2ba2c..8dd73bb 100644 --- a/crates/apps/nigig-marikiti/Cargo.toml +++ b/crates/apps/nigig-marikiti/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-mobility/Cargo.toml b/crates/apps/nigig-mobility/Cargo.toml index 1c8317a..65892e6 100644 --- a/crates/apps/nigig-mobility/Cargo.toml +++ b/crates/apps/nigig-mobility/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-mpesa/Cargo.toml b/crates/apps/nigig-mpesa/Cargo.toml index 205db6e..1690855 100644 --- a/crates/apps/nigig-mpesa/Cargo.toml +++ b/crates/apps/nigig-mpesa/Cargo.toml @@ -5,10 +5,40 @@ version = "0.1.0" edition = "2021" [features] -default = [] +## USSD automation is ON by default. +## +## Review item 0.1 asked for dispatch behind a compile-time flag. That was +## implemented as default-off, which made the *334# automation — the app's +## primary function — unreachable in an ordinary build, and unreachable at +## all from the APK crate until 9f0e133. +## +## The containment requirement has not been dropped; it has moved to release +## packaging, which is where a shipping decision belongs. A Play-listed build +## must be produced with `--no-default-features` (plus the features it does +## want), and the unresolved Google Play policy risk is recorded in +## REVIEWS/adr/0007-payment-platform-boundary.md. +## +## Turning it off for a build: +## cargo build -p pageflipnav --no-default-features --features native +default = ["demo"] +## Forwards to `nigig-pay-ui/demo`, which enables USSD dispatch, auto-retry +## and bulk pay. +## +## Off by default on purpose (review items 0.1/0.3): the default build is a +## tracker that never dials USSD and never handles an M-Pesa PIN. Without +## this feature the Pay sheet reports that dispatch is unavailable, which is +## the intended behaviour rather than a build error. +## +## Enabling it turns on AccessibilityService-driven USSD automation. That +## remains an unresolved Google Play policy risk (review item 5.2, +## REVIEWS/adr/0007) and must not be enabled for a shipped build. +## +## USSD only has a real backend on Android; every other target returns +## `PermanentlyUnavailable`, so this feature does nothing useful on desktop. +demo = ["nigig-pay-ui/demo"] [dependencies] -makepad-widgets = { workspace = true, features = ["test"] } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc", features = ["test"] } nigig-core = { path = "../../nigig-core" } # The exchange tab's spread analysis. p2p-intel is a nested workspace that # the root excludes, but a path dependency into it resolves fine and keeps diff --git a/crates/apps/nigig-mpesa/src/pages/transactions/transact.rs b/crates/apps/nigig-mpesa/src/pages/transactions/transact.rs index 0cb52bd..a41e519 100644 --- a/crates/apps/nigig-mpesa/src/pages/transactions/transact.rs +++ b/crates/apps/nigig-mpesa/src/pages/transactions/transact.rs @@ -1328,7 +1328,7 @@ impl MpesaTrackerScreen { } } - #[cfg(not(target_os = "android"))] + #[cfg(all(not(target_os = "android"), feature = "demo"))] { let dummy = robius_sms::dummy_messages(); let mut parsed = Vec::new(); @@ -1356,6 +1356,11 @@ impl MpesaTrackerScreen { return; } + #[cfg(all(not(target_os = "android"), not(feature = "demo")))] + { + self.status_text = "Desktop: SMS scanning disabled (compile with `--features demo`)".to_string(); + } + // Offline fallback: load cached metadata. body is empty due to // #[serde(skip)], so no parsing occurs here — this only shows // previously-parsed transactions from the PSV store. diff --git a/crates/apps/nigig-pay-ui/Cargo.toml b/crates/apps/nigig-pay-ui/Cargo.toml index 3c71312..80f233d 100644 --- a/crates/apps/nigig-pay-ui/Cargo.toml +++ b/crates/apps/nigig-pay-ui/Cargo.toml @@ -6,10 +6,35 @@ edition = "2021" description = "Shared payment UI and logic for nigig-mpesa and nigig-pay." [features] +## This leaf crate stays default-off on purpose. The app crates above it +## enable `demo` through their own defaults and depend on this crate with +## `default-features = false`, so a packaging build that passes +## `--no-default-features` to the app actually turns the automation off +## instead of having it re-enabled here. +## +## Original note, still applicable: +## USSD automation is ON by default in the app crates. +## +## Review item 0.1 asked for dispatch behind a compile-time flag. That was +## implemented as default-off, which made the *334# automation — the app's +## primary function — unreachable in an ordinary build, and unreachable at +## all from the APK crate until 9f0e133. +## +## The containment requirement has not been dropped; it has moved to release +## packaging, which is where a shipping decision belongs. A Play-listed build +## must be produced with `--no-default-features` (plus the features it does +## want), and the unresolved Google Play policy risk is recorded in +## REVIEWS/adr/0007-payment-platform-boundary.md. +## +## Turning it off for a build: +## cargo build -p pageflipnav --no-default-features --features native default = [] +## Enable USSD dispatch, auto-retry, and bulk pay. +## Production builds must NOT enable this until authorized provider integration exists. +demo = [] [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-pay-domain = { path = "../../nigig-pay-domain" } # Session correlation (review items 2.7 / 5.3). The registry that refuses an diff --git a/crates/apps/nigig-pay-ui/src/pay_flow/pay_flow_handler.rs b/crates/apps/nigig-pay-ui/src/pay_flow/pay_flow_handler.rs index 3195241..559bf2d 100644 --- a/crates/apps/nigig-pay-ui/src/pay_flow/pay_flow_handler.rs +++ b/crates/apps/nigig-pay-ui/src/pay_flow/pay_flow_handler.rs @@ -153,7 +153,14 @@ impl RetryState { impl PayFlowHandler { pub fn with_loaded_store() -> Self { import_legacy_pending_records_once(); - let pending_store = PendingTransactionStore::load(); + // The legacy JSON store is retained only for explicit demo-mode + // compatibility. Default builds import it once into SQLite and never + // use it as active payment state. + let pending_store = if cfg!(feature = "demo") { + PendingTransactionStore::load() + } else { + PendingTransactionStore::default() + }; Self { pending_store, current: None, @@ -217,6 +224,13 @@ pub fn on_payment_started( estimated_fee: u64, fingerprint_enabled: bool, ) { + // Defense in depth: parent widgets must not be able to create legacy + // pending records or drive USSD merely by bypassing SharedPaySheet. + if !cfg!(feature = "demo") { + log!("[PayFlow] ignored legacy payment start for {id}: USSD demo feature is disabled"); + return; + } + HANDLER.with(|h| { let mut h = h.borrow_mut(); let kind = request.kind.unwrap_or(TransactionKind::SendMoney); @@ -240,7 +254,7 @@ pub fn on_payment_started( robius_fingerprinting::BiometricStrength::Strong, ).unwrap_or(false); log!("[PayFlow] on_payment_started id={id} fp_enabled={fingerprint_enabled} has_hw={has_hw}"); - if fingerprint_enabled && has_hw { + if cfg!(feature = "demo") && fingerprint_enabled && has_hw { log!("[PayFlow] starting biometric auth"); // Review item Q2. This was `.unwrap()`. It is reachable only if // `pending_request` is None while `current` is Some, which the @@ -642,6 +656,18 @@ fn dispatch_ussd(h: &mut PayFlowHandler) { }; log!("[PayFlow] dispatch_ussd: kind={:?} amount={}", request.kind, request.amount); + // SAFETY GATE: USSD dispatch is disabled in production builds. + // Enable the `demo` feature to allow real USSD automation. + if !cfg!(feature = "demo") { + let reason = "USSD dispatch disabled: compile with `--features demo` to enable".to_string(); + log!("[PayFlow] {reason}"); + if let Some(id) = h.current.clone() { + h.pending_store.mark_failed(&id, reason.clone()); + h.dispatch_error = Some((id, reason)); + } + return; + } + match robius_ussd::begin_transaction(&request) { Ok(()) => { if let Some(id) = h.current.clone() { @@ -676,6 +702,16 @@ fn dispatch_ussd(h: &mut PayFlowHandler) { } fn retry_dispatch(h: &mut PayFlowHandler) -> Option { + // SAFETY GATE: retries are disabled in production builds. + if !cfg!(feature = "demo") { + let id = h.retry.failed_id.clone().unwrap_or_default(); + h.retry.clear(); + let reason = "auto-retry disabled: compile with `--features demo` to enable".to_string(); + h.pending_store.mark_failed(&id, reason.clone()); + h.current = None; + return Some(PayFlowEvent::Failed { id, reason }); + } + let request = h.retry.failed_request.take()?; let id = h.retry.failed_id.clone()?; let attempt = h.retry.count; @@ -710,7 +746,13 @@ fn retry_dispatch(h: &mut PayFlowHandler) -> Option { /// Start a batch of bulk payments. Clears any current transaction and /// begins processing the queue sequentially. +/// +/// **DISABLED in production.** Compile with `--features demo` to enable. pub fn start_bulk_payments(requests: Vec<(String, UssdTransactionRequest)>) { + if !cfg!(feature = "demo") { + log!("[PayFlow] bulk payments disabled: compile with `--features demo` to enable"); + return; + } HANDLER.with(|h| { let mut h = h.borrow_mut(); // Cancel any in-flight transaction. diff --git a/crates/apps/nigig-pay-ui/src/shared_pay_sheet.rs b/crates/apps/nigig-pay-ui/src/shared_pay_sheet.rs index a48e678..6978ba3 100644 --- a/crates/apps/nigig-pay-ui/src/shared_pay_sheet.rs +++ b/crates/apps/nigig-pay-ui/src/shared_pay_sheet.rs @@ -6,6 +6,9 @@ use makepad_widgets::*; use nigig_pay_domain::{bulk_progress_line, FlowStage}; use robius_ussd::{TransactionKind, UssdTransactionRequest}; +// Only the demo-gated PIN field uses these (review item 0.3): a packaging +// build has no `form_pin`, so the import would be dead there. +#[cfg(feature = "demo")] use zeroize::{Zeroize, Zeroizing}; use crate::pay_flow::PayFlowEvent; @@ -126,45 +129,6 @@ fn device_has_fingerprints() -> bool { ).unwrap_or(false) } -fn classify_kind_from_sub(sub: &str) -> robius_ussd::TransactionKind { - use robius_ussd::TransactionKind; - let lower = sub.to_lowercase(); - if lower.contains("buy good") || lower.contains("till") { - TransactionKind::Till - } else if lower.contains("pay bill") || lower.contains("paybill") { - TransactionKind::Paybill - } else if lower.contains("withdraw") || lower.contains("agent") { - TransactionKind::WithdrawAgent - } else if lower.contains("pochi") { - TransactionKind::Pochi - } else { - TransactionKind::SendMoney - } -} - -fn relative_time_label(now_ms: i64, tx_ms: i64) -> String { - let diff_s = ((now_ms - tx_ms) / 1000).max(0) as u64; - if diff_s < 60 { - "just now".into() - } else if diff_s < 3600 { - format!("{}m ago", diff_s / 60) - } else if diff_s < 86400 { - format!("{}h ago", diff_s / 3600) - } else { - format!("{}d ago", diff_s / 86400) - } -} - -/// Lightweight snapshot of a past M-Pesa transaction for the recent-list. -#[derive(Clone)] -struct RecentTxn { - phone: String, - party: String, - amount_ksh: u64, - kind: TransactionKind, - time_label: String, -} - // ── SharedPaySheet widget ───────────────────────────────────────────────────── #[derive(Script, Widget)] @@ -201,7 +165,12 @@ pub struct SharedPaySheet { // Legacy demo-only secret. Zeroizing clears its backing allocation when // the widget is dropped; default builds also hide and clear this field. /// Custom M-Pesa PIN capture (review item 0.3). - #[rust] + /// + /// Compiled **only** into a `demo` build. The review requires removing + /// custom PIN capture from the default product, and a field that does not + /// exist cannot be populated by a stale UI definition, a hot reload, or a + /// widget that is merely hidden rather than absent. + #[cfg(feature = "demo")] form_pin: Zeroizing, #[rust] is_open: bool, @@ -255,6 +224,7 @@ pub struct SharedPaySheet { #[rust] confirm_send: bool, #[rust] + #[cfg(feature = "demo")] pin_visible: bool, #[rust] @@ -265,20 +235,6 @@ pub struct SharedPaySheet { bulk_in_progress: bool, #[rust] cost_text: String, - - // ── Fee-avoidance split ───────────────────────────────────────────────── - #[rust] - avoid_fees_split: bool, - #[rust] - split_batch_count: usize, - - // ── Recent transactions list ──────────────────────────────────────────── - #[rust] - recent_txns_visible: bool, - #[rust] - recent_txns: Vec, - #[rust] - selected_recent_index: Option, } impl Widget for SharedPaySheet { @@ -286,20 +242,12 @@ impl Widget for SharedPaySheet { let actions = cx.capture_actions(|cx| self.view.handle_event(cx, event, scope)); // ── Periodic network indicator refresh (every ~5s) ───────────────────── - // Call sim_network_status() directly from the UI thread where - // with_activity works reliably, instead of using the background-thread - // cache which may return Unknown if the JNI env wasn't available there. let now_ms = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0); if now_ms - self.last_network_check_ms > 5000 { self.last_network_check_ms = now_ms; - let sim_status = robius_ussd::sim_network_status(); - let (text, color) = match sim_status { - robius_ussd::SimNetworkStatus::Active => ("SIM Ready".to_string(), 0xFF4CAF50_u32), - robius_ussd::SimNetworkStatus::NoSim => ("No SIM".to_string(), 0xFFFF5252_u32), - robius_ussd::SimNetworkStatus::NoSignal => ("No Signal".to_string(), 0xFFFF5252_u32), - robius_ussd::SimNetworkStatus::AirplaneMode => ("Airplane".to_string(), 0xFFFF5252_u32), - robius_ussd::SimNetworkStatus::Unknown => ("Unknown".to_string(), 0xFFFF9800_u32), - }; + let ns = nigig_core::network::current(); + let text = ns.connectivity.label().to_string(); + let color = if ns.connectivity.is_connected() { 0xFF4CAF50_u32 } else { 0xFFFF5252_u32 }; self.set_network_indicator(cx, &text, color); } @@ -404,83 +352,13 @@ impl Widget for SharedPaySheet { self.refresh_cost_preview(cx); } - // ── Avoid Fees Split ───────────────────────────────────────────────── - if let Some(checked) = self.view.check_box(cx, ids!(split_fees_checkbox)).changed(&actions) { - self.avoid_fees_split = checked; - self.refresh_cost_preview(cx); - } - - // ── Recent Transactions Header Click ───────────────────────────────── - if self.view.view(cx, ids!(recent_header)) - .finger_up(&actions) - .is_some_and(|fe| fe.was_tap()) - { - self.recent_txns_visible = !self.recent_txns_visible; - self.view.view(cx, ids!(recent_section)).set_visible(cx, self.recent_txns_visible); - let arrow = if self.recent_txns_visible { "▼" } else { "▶" }; - self.view.label(cx, ids!(recent_arrow)).set_text(cx, arrow); - if self.recent_txns_visible { - self.reload_recent_txns(); - self.update_recent_header_visibility(cx); - self.populate_recent_rows(cx); - } - self.view.redraw(cx); - } - - // ── Recent row clicks (View finger_up like room list) ──────────── - macro_rules! handle_recent_row { - ($idx:expr, $row_id:ident) => { - if self.view.view(cx, ids!($row_id)) - .finger_up(&actions) - .is_some_and(|fe| fe.was_tap()) - { - if let Some(txn) = self.recent_txns.get($idx).cloned() { - self.form_phone = txn.phone.clone(); - self.form_amount = txn.amount_ksh; - self.payment_kind = txn.kind; - self.payment_flow = match txn.kind { - TransactionKind::Till => PaymentFlow::Till, - TransactionKind::Paybill => PaymentFlow::Paybill, - TransactionKind::WithdrawAgent => PaymentFlow::Withdraw, - _ => PaymentFlow::SendMoney, - }; - self.view.text_input(cx, ids!(phone_input)).set_text(cx, &self.form_phone); - self.view.text_input(cx, ids!(pay_amount_input)).set_text(cx, &self.form_amount.to_string()); - self.apply_payment_flow(cx, self.payment_flow); - self.refresh_cost_preview(cx); - - // Auto-close the recent list after selection - self.recent_txns_visible = false; - self.view.view(cx, ids!(recent_section)).set_visible(cx, false); - self.view.label(cx, ids!(recent_arrow)).set_text(cx, "▶"); - - // Track selected row for highlight - self.selected_recent_index = Some($idx); - self.populate_recent_rows(cx); - - self.set_status(cx, &format!("Loaded: {} KSh {} — edit amount", txn.party, txn.amount_ksh)); - self.view.redraw(cx); - } - } - }; - } - handle_recent_row!(0, recent_row_0); - handle_recent_row!(1, recent_row_1); - handle_recent_row!(2, recent_row_2); - handle_recent_row!(3, recent_row_3); - handle_recent_row!(4, recent_row_4); - handle_recent_row!(5, recent_row_5); - handle_recent_row!(6, recent_row_6); - handle_recent_row!(7, recent_row_7); - handle_recent_row!(8, recent_row_8); - handle_recent_row!(9, recent_row_9); - // ── Confirm checkbox ────────────────────────────────────────────────── if let Some(checked) = self.view.check_box(cx, ids!(confirm_checkbox)).changed(&actions) { self.confirm_send = checked; } // ── PIN visibility toggle ────────────────────────────────────────────── + #[cfg(feature = "demo")] if self.view.button(cx, ids!(pin_eye_btn)).clicked(&actions) { self.pin_visible = !self.pin_visible; self.view.text_input(cx, ids!(pin_input)).set_is_password(cx, !self.pin_visible); @@ -504,6 +382,18 @@ impl Widget for SharedPaySheet { self.cancel_scan(cx); } + // ── Permission / accessibility toggles ──────────────────────────────── + if let Some(true) = self.view.check_box(cx, ids!(phone_perm_toggle)).changed(&actions) { + let _ = robius_ussd::request_permissions(&[robius_ussd::Permission::CallPhone]); + self.refresh_phone_perm(cx); + } + if let Some(true) = self.view.check_box(cx, ids!(ussd_toggle)).changed(&actions) { + if let Err(e) = robius_ussd::open_accessibility_settings() { + self.set_status(cx, &format!("Couldn't open settings: {e}")); + } + self.refresh_ussd_status(cx); + } + // ── Bulk mode toggle ─────────────────────────────────────────────────── if let Some(checked) = self.view.check_box(cx, ids!(bulk_toggle)).changed(&actions) { self.bulk_mode = checked; @@ -538,17 +428,14 @@ impl Widget for SharedPaySheet { self.csv_contacts = contacts; self.refresh_csv_summary(cx); self.refresh_cost_breakdown(cx); - // Show CSV file name on both the regular and bulk file buttons + // Show CSV file name on the file button let display = if self.csv_file_name.len() > 20 { format!("...{}", &self.csv_file_name[..17]) } else { self.csv_file_name.clone() }; - let label = format!("📂 {}", display); - self.view.button(cx, ids!(csv_file_btn)).set_text(cx, &label); + self.view.button(cx, ids!(csv_file_btn)).set_text(cx, &format!("📂 {}", display)); self.view.button(cx, ids!(csv_preview_icon)).set_visible(cx, true); - self.view.button(cx, ids!(bulk_csv_file_btn)).set_text(cx, &label); - self.view.button(cx, ids!(bulk_csv_preview_icon)).set_visible(cx, true); self.set_status(cx, &format!("Loaded {} contacts from CSV", self.csv_contacts.len())); } } @@ -605,79 +492,6 @@ impl Widget for SharedPaySheet { }); } - // ── Bulk CSV file button (inside bulk_section) ────────────────────────── - if self.view.button(cx, ids!(bulk_csv_file_btn)).clicked(&actions) { - use robius_file_picker::FileDialog; - let _ = FileDialog::new() - .add_filter("CSV", &["csv"]) - .set_title("Pick CSV file with contacts") - .pick_file(|result| { - match result { - Ok(Some(picked)) => { - let file_name = picked.file_name().unwrap_or("unknown.csv").to_string(); - match picked.into_local_file() { - Ok(local) => { - match std::fs::read_to_string(local.path()) { - Ok(content) => { - match bulk_pay::parse_csv(&content) { - Ok(contacts) => { - if let Ok(mut pending) = PENDING_CSV.lock() { - *pending = Some((file_name, contacts)); - } - } - Err(e) => { - if let Ok(mut pending) = PENDING_CSV.lock() { - *pending = Some((format!("error: {e}"), Vec::new())); - } - } - } - } - Err(e) => { - if let Ok(mut pending) = PENDING_CSV.lock() { - *pending = Some((format!("read error: {e}"), Vec::new())); - } - } - } - } - Err(e) => { - if let Ok(mut pending) = PENDING_CSV.lock() { - *pending = Some((format!("file error: {e}"), Vec::new())); - } - } - } - } - Ok(None) => { /* user cancelled */ } - Err(e) => { - if let Ok(mut pending) = PENDING_CSV.lock() { - *pending = Some((format!("picker error: {e}"), Vec::new())); - } - } - } - }); - } - - // ── Bulk CSV preview icon ─────────────────────────────────────────────── - if self.view.button(cx, ids!(bulk_csv_preview_icon)).clicked(&actions) - && !self.csv_contacts.is_empty() - { - let preview: String = self.csv_contacts.iter() - .map(|c| format!("{} — KSh {}", c.phone, c.amount)) - .collect::>() - .join("\n"); - self.set_status(cx, &preview); - } - - // ── Bulk Pick Contact Button ──────────────────────────────────────────── - if self.view.button(cx, ids!(bulk_pick_contact_btn)).clicked(&actions) { - let stack = self.view.stack_navigation(cx, ids!(sheet_stack)); - if !stack.is_transitioning() { - if let Some((view_id, _)) = stack.create_view_from_template(cx, id!(ContactPickerStackView)) { - stack.set_title(cx, view_id, "Select Contact"); - stack.push(cx, view_id); - } - } - } - // ── Sub-widget actions ──────────────────────────────────────────────── for action in actions.iter() { if let TransactionKindPickerAction::Selected(kind) = action.as_widget_action().cast() { @@ -705,13 +519,6 @@ impl Widget for SharedPaySheet { self.form_amount = t.trim().parse::().unwrap_or(0); } if let Some(t) = self.view.text_input(cx, ids!(phone_input)).changed(&actions) { self.form_phone = t; } - - // ── Phone clear button ───────────────────────────────────────────── - if self.view.button(cx, ids!(phone_clear_btn)).clicked(&actions) { - self.form_phone.clear(); - self.view.text_input(cx, ids!(phone_input)).set_text(cx, ""); - self.view.redraw(cx); - } // ── Pick Contact Button (Push Stack Navigation) ─────────────────────── if self.view.button(cx, ids!(btn_pick_contact)).clicked(&actions) { @@ -742,9 +549,23 @@ impl Widget for SharedPaySheet { if let Some(t) = self.view.text_input(cx, ids!(account_input)).changed(&actions) { self.form_account = t; } if let Some(t) = self.view.text_input(cx, ids!(agent_input)).changed(&actions) { self.form_agent = t; } if let Some(t) = self.view.text_input(cx, ids!(store_input)).changed(&actions) { self.form_store = t; } + #[cfg(feature = "demo")] if let Some(t) = self.view.text_input(cx, ids!(pin_input)).changed(&actions) { self.form_pin = Zeroizing::new(t); } + // In a default build there is no `form_pin` to write to. The input is + // also removed from the DSL, so nothing can type into it; this arm + // wipes any text a stale or hot-reloaded UI definition managed to + // surface, and is deliberately the only place that touches it. + #[cfg(not(feature = "demo"))] + if self + .view + .text_input(cx, ids!(pin_input)) + .changed(&actions) + .is_some() + { + self.view.text_input(cx, ids!(pin_input)).set_text(cx, ""); + } if let Some(t) = self.view.text_input(cx, ids!(merchant_input)).changed(&actions) { self.form_merchant = t; } // ── CTA ─────────────────────────────────────────────────────────────── @@ -947,7 +768,6 @@ impl SharedPaySheet { self.csv_contacts.clear(); self.csv_file_name.clear(); self.view.button(cx, ids!(csv_preview_icon)).set_visible(cx, false); - self.view.button(cx, ids!(bulk_csv_preview_icon)).set_visible(cx, false); self.bulk_total = 0; self.bulk_done = 0; self.cost_text.clear(); @@ -1101,7 +921,7 @@ impl SharedPaySheet { // ── Cost preview ────────────────────────────────────────────────────────── - fn refresh_cost_preview(&mut self, cx: &mut Cx) { + fn refresh_cost_preview(&self, cx: &mut Cx) { let kind = self.payment_kind; // Review item B5: an amount with no published fee band must read as // unknown, never as free. `unwrap_or(0)` here previously rendered an @@ -1163,27 +983,6 @@ impl SharedPaySheet { None => "KSh: — (fee unavailable)".to_string(), }; self.view.label(cx, ids!(adjusted_label)).set_text(cx, &adjusted_text); - - // ── Avoid fees split checkbox ──────────────────────────────────────── - // Only useful when the amount exceeds KSh 100 (the fee-free band). - let split_useful = self.form_amount > 100; - self.view.check_box(cx, ids!(split_fees_checkbox)) - .set_disabled(cx, !split_useful); - if !split_useful { - self.view.check_box(cx, ids!(split_fees_checkbox)) - .set_active(cx, false, Animate::No); - self.avoid_fees_split = false; - self.split_batch_count = 0; - } - if self.avoid_fees_split && split_useful { - let batches = ((self.form_amount + 99) / 100) as usize; - self.split_batch_count = batches; - self.view.label(cx, ids!(split_fees_info)) - .set_text(cx, &format!("×{batches} batches")); - } else { - self.split_batch_count = 0; - self.view.label(cx, ids!(split_fees_info)).set_text(cx, ""); - } } // ── CTA handlers ───────────────────────────────────────────────────────── @@ -1199,10 +998,41 @@ impl SharedPaySheet { } fn start_payment_flow(&mut self, cx: &mut Cx) { + // Production builds are tracker-only until an approved provider + // gateway replaces the legacy accessibility/USSD automation. Do not + // ask for or construct a PIN-bearing request when dispatch is absent. + if !cfg!(feature = "demo") { + // This is a product boundary, not a build error. The previous + // wording ("unavailable in this build") read like something was + // broken and gave no way forward, so users reported it as a bug. + // Say what the app does instead, and what to do next. + // The build marker is deliberately in the user-visible string. + // + // This message was reported as a bug three times while the fix + // was already on main, because there was no way to tell from the + // screen whether the running binary contained it. A stale APK and + // a current one looked identical. `tracker-2` is cheap to read + // aloud in a bug report and settles that question immediately. + // The USSD automation is *gated*, not removed. An earlier version + // of this string said only "this app tracks payments", which read + // as though the capability had been deleted and hid the way to + // turn it on. The original wording named the feature flag; that + // hint is restored here alongside the honest framing. + self.set_status( + cx, + "Automated *334# payment is off in this build. \ + Rebuild with `--features demo` on Android to enable it, \ + or send in the M-Pesa app and it will appear here \ + automatically. No M-Pesa PIN was requested or stored. \ + [tracker-3]", + ); + return; + } if self.form_amount == 0 { self.set_status(cx, "Enter an amount"); return; } + #[cfg(feature = "demo")] if self.form_pin.trim().is_empty() { self.set_status(cx, "M-Pesa PIN required"); return; @@ -1219,7 +1049,10 @@ impl SharedPaySheet { let request = UssdTransactionRequest { kind: Some(self.payment_kind), amount, + #[cfg(feature = "demo")] pin: self.form_pin.to_string(), + #[cfg(not(feature = "demo"))] + pin: String::new(), phone: self.form_phone.clone(), till: self.form_till.clone(), business: self.form_business.clone(), @@ -1233,7 +1066,21 @@ impl SharedPaySheet { return; } - if !self.prompt_for_ussd_prerequisites(cx) { + self.refresh_phone_perm(cx); + self.refresh_ussd_status(cx); + + if !self.phone_perm_granted { + self.payment_in_progress = false; + self.view.button(cx, ids!(cta_btn)).set_enabled(cx, true); + self.set_status(cx, "Grant Phone permission to send payments"); + self.view.redraw(cx); + return; + } + if !self.ussd_enabled { + self.payment_in_progress = false; + self.view.button(cx, ids!(cta_btn)).set_enabled(cx, true); + self.set_status(cx, "Enable Accessibility to send payments"); + self.view.redraw(cx); return; } @@ -1243,63 +1090,6 @@ impl SharedPaySheet { return; } - // ── Fee-avoidance split: dispatch as multiple ≤100 KSh batches ───── - if self.avoid_fees_split && amount > 100 && self.split_batch_count > 1 { - let kind = self.payment_kind; - let pin = self.form_pin.to_string(); - - let full_batches = amount / 100; - let remainder = amount % 100; - let total_batches = if remainder > 0 { - full_batches + 1 - } else { - full_batches - }; - - let mut requests = Vec::with_capacity(total_batches as usize); - let base_ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - - for i in 0..total_batches { - let batch_amount = if i < full_batches { 100 } else { remainder }; - if batch_amount == 0 { - continue; - } - let id = format!("split-{}-{}", base_ts, i); - requests.push(( - id, - UssdTransactionRequest { - kind: Some(kind), - amount: batch_amount, - pin: pin.clone(), - phone: self.form_phone.clone(), - till: self.form_till.clone(), - business: self.form_business.clone(), - account: self.form_account.clone(), - agent: self.form_agent.clone(), - store: self.form_store.clone(), - confirm_send: false, - }, - )); - } - - self.clear_legacy_pin(cx); - self.bulk_total = requests.len(); - self.bulk_done = 0; - self.bulk_in_progress = true; - self.payment_in_progress = true; - self.view.button(cx, ids!(cta_btn)).set_enabled(cx, false); - self.set_status(cx, &format!( - "Splitting KSh {amount} into {total_batches} × KSh 100 batches…" - )); - - crate::pay_flow::start_bulk_payments(requests); - cx.widget_action(self.widget_uid(), SharedPaySheetAction::BulkPaymentStarted(total_batches as usize)); - return; - } - let id = format!( "ptx-{}", std::time::SystemTime::now() @@ -1343,6 +1133,7 @@ impl SharedPaySheet { /// Wipe the legacy demo PIN after constructing the one request that must /// own it. Default builds never reach this path. fn clear_legacy_pin(&mut self, cx: &mut Cx) { + #[cfg(feature = "demo")] self.form_pin.zeroize(); self.view.text_input(cx, ids!(pin_input)).set_text(cx, ""); } @@ -1486,49 +1277,6 @@ impl SharedPaySheet { self.view.redraw(cx); } - /// Checks that both prerequisites for USSD payment — the `CALL_PHONE` - /// runtime permission and the enabled Accessibility service — are satisfied - /// *before* anything is dispatched. If one is missing it auto-prompts the - /// user right then (OS permission dialog / Accessibility settings page) and - /// explains what to do, so a payment never silently fails to dial after the - /// details are entered. Returns `false` when a prompt was raised. - fn prompt_for_ussd_prerequisites(&mut self, cx: &mut Cx) -> bool { - self.refresh_phone_perm(cx); - self.refresh_ussd_status(cx); - - if !self.phone_perm_granted { - self.payment_in_progress = false; - self.view.button(cx, ids!(cta_btn)).set_enabled(cx, true); - let _ = robius_ussd::request_permissions(&[robius_ussd::Permission::CallPhone]); - self.set_status( - cx, - "Phone permission is needed to dial USSD and send the \ - payment. Allow it on the system prompt that just opened.", - ); - self.view.redraw(cx); - return false; - } - - if !self.ussd_enabled { - self.payment_in_progress = false; - self.view.button(cx, ids!(cta_btn)).set_enabled(cx, true); - if let Err(e) = robius_ussd::open_accessibility_settings() { - self.set_status(cx, &format!("Couldn't open Accessibility settings: {e}")); - } else { - self.set_status( - cx, - "USSD needs the accessibility service enabled to fill the \ - M-Pesa dialog. Turn on \"Pageflipnav\" under Accessibility \ - on the page that just opened.", - ); - } - self.view.redraw(cx); - return false; - } - - true - } - // ── Fingerprint scan feedback ───────────────────────────────────────────── pub fn set_scan_feedback(&mut self, cx: &mut Cx, fb: FingerprintScanFeedback) { @@ -1813,9 +1561,7 @@ impl SharedPaySheet { } else { self.csv_file_name.clone() }; - let label = format!("📂 {}", display); - self.view.button(cx, ids!(csv_file_btn)).set_text(cx, &label); - self.view.button(cx, ids!(bulk_csv_file_btn)).set_text(cx, &label); + self.view.button(cx, ids!(csv_file_btn)).set_text(cx, &format!("📂 {}", display)); } fn refresh_cost_breakdown(&mut self, cx: &mut Cx) { @@ -1865,133 +1611,48 @@ impl SharedPaySheet { self.update_cta_text(cx); } - /// Load the most recent M-Pesa debit transactions for the "Recent" list. - /// Deduplicates by phone number, keeping the most recent per recipient, - /// and sorts by frequency (most often paid first). - fn reload_recent_txns(&mut self) { - use nigig_core::persistence::mpesa::parser::{MpesaTransaction, TransactionType}; - use nigig_core::persistence::mpesa::store::MpesaTransactionStore; - use robius_ussd::TransactionKind; - use std::collections::HashMap; - use std::time::{SystemTime, UNIX_EPOCH}; - - let store = MpesaTransactionStore::load(); - let now_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0); - - // First pass: build a map of phone → (most recent txn, frequency count). - // `transactions` is ordered oldest-first, so later entries are more recent. - let mut by_phone: HashMap = HashMap::new(); - - for tx in store.all() { - if tx.transaction_type != TransactionType::Debit { - continue; - } - let key = tx.party_phone.clone(); - match by_phone.get_mut(&key) { - Some((count, latest)) => { - *count += 1; - // Keep the more recent transaction (higher received_at = more recent). - if tx.received_at > latest.received_at { - *latest = tx.clone(); - } - } - None => { - by_phone.insert(key, (1, tx.clone())); - } - } - } - - // Second pass: convert to RecentTxn, sort by frequency desc then recency desc. - let mut entries: Vec<(usize, RecentTxn)> = by_phone - .into_values() - .map(|(freq, tx)| { - let kind = classify_kind_from_sub(&tx.classification.sub_category); - let time_label = relative_time_label(now_ms, tx.received_at); - let txn = RecentTxn { - phone: tx.party_phone.clone(), - party: tx.party.clone(), - amount_ksh: tx.amount as u64, - kind, - time_label, - }; - (freq, txn) - }) - .collect(); - - // Sort: highest frequency first; ties broken by most recent. - entries.sort_by(|a, b| { - b.0.cmp(&a.0) - .then(b.1.time_label.cmp(&a.1.time_label)) - }); - - self.recent_txns = entries.into_iter().map(|(_, txn)| txn).take(10).collect(); - } - - /// Show/hide the recent-transactions header based on whether data exists. - fn update_recent_header_visibility(&self, cx: &mut Cx) { - let has_txns = !self.recent_txns.is_empty(); - self.view.view(cx, ids!(recent_header)).set_visible(cx, has_txns); - if !has_txns { - self.view.view(cx, ids!(recent_section)).set_visible(cx, false); - self.view.label(cx, ids!(recent_count_label)).set_text(cx, &format!("({})", self.recent_txns.len())); - } else { - self.view.label(cx, ids!(recent_count_label)).set_text(cx, &format!("({})", self.recent_txns.len())); - } - } - - /// Fill the 10 recent-row views with transaction data (or blank). - fn populate_recent_rows(&self, cx: &mut Cx) { - macro_rules! set_row { - ($idx:expr, $txn:expr, $row_id:ident, $name_id:ident, $info_id:ident, $amount_id:ident) => { - if let Some(txn) = $txn { - let display_name = if txn.party.len() > 20 { &txn.party[..20] } else { &txn.party }; - let info = match txn.kind { - TransactionKind::SendMoney => format!("{} · Send", &txn.phone), - TransactionKind::Pochi => format!("{} · Pochi", &txn.phone), - TransactionKind::Till => format!("{} · Till", &txn.phone), - TransactionKind::Paybill => format!("{} · Bill", &txn.phone), - TransactionKind::WithdrawAgent => format!("{} · W/D", &txn.phone), - }; - self.view.label(cx, ids!($name_id)).set_text(cx, display_name); - self.view.label(cx, ids!($info_id)).set_text(cx, &info); - self.view.label(cx, ids!($amount_id)).set_text(cx, &format!("KSh {}", txn.amount_ksh)); - self.view.view(cx, ids!($row_id)).set_visible(cx, true); - } else { - self.view.view(cx, ids!($row_id)).set_visible(cx, false); - } - }; - } - set_row!(0, self.recent_txns.get(0), recent_row_0, recent_row_0_name, recent_row_0_info, recent_row_0_amount); - set_row!(1, self.recent_txns.get(1), recent_row_1, recent_row_1_name, recent_row_1_info, recent_row_1_amount); - set_row!(2, self.recent_txns.get(2), recent_row_2, recent_row_2_name, recent_row_2_info, recent_row_2_amount); - set_row!(3, self.recent_txns.get(3), recent_row_3, recent_row_3_name, recent_row_3_info, recent_row_3_amount); - set_row!(4, self.recent_txns.get(4), recent_row_4, recent_row_4_name, recent_row_4_info, recent_row_4_amount); - set_row!(5, self.recent_txns.get(5), recent_row_5, recent_row_5_name, recent_row_5_info, recent_row_5_amount); - set_row!(6, self.recent_txns.get(6), recent_row_6, recent_row_6_name, recent_row_6_info, recent_row_6_amount); - set_row!(7, self.recent_txns.get(7), recent_row_7, recent_row_7_name, recent_row_7_info, recent_row_7_amount); - set_row!(8, self.recent_txns.get(8), recent_row_8, recent_row_8_name, recent_row_8_info, recent_row_8_amount); - set_row!(9, self.recent_txns.get(9), recent_row_9, recent_row_9_name, recent_row_9_info, recent_row_9_amount); - } - fn start_bulk_payment_flow(&mut self, cx: &mut Cx) { + // Bulk payment is never available outside the explicit demo feature. + // Return before reading/cloning any PIN-bearing form state. + if !cfg!(feature = "demo") { + // Same product boundary as the single-payment path, and the same + // reason for the wording: "unavailable in this build" describes + // the binary rather than the product and offers no next step. + self.set_status( + cx, + "Automated bulk payment is off in this build. \ + Rebuild with `--features demo` on Android to enable it. \ + No M-Pesa PIN was requested or stored. [tracker-3]", + ); + return; + } if self.csv_contacts.is_empty() { self.set_status(cx, "Pick a CSV file first"); return; } + #[cfg(feature = "demo")] if self.form_pin.trim().is_empty() { self.set_status(cx, "M-Pesa PIN required"); return; } - if !self.prompt_for_ussd_prerequisites(cx) { + self.refresh_phone_perm(cx); + self.refresh_ussd_status(cx); + + if !self.phone_perm_granted { + self.set_status(cx, "Grant Phone permission to send payments"); + return; + } + if !self.ussd_enabled { + self.set_status(cx, "Enable Accessibility to send payments"); return; } let kind = self.payment_kind; + #[cfg(feature = "demo")] let pin = self.form_pin.to_string(); + #[cfg(not(feature = "demo"))] + let pin = String::new(); let total = self.csv_contacts.len(); let mut requests = Vec::with_capacity(total); @@ -2083,22 +1744,25 @@ impl ScriptHook for SharedPaySheet { self.cost_enabled = false; self.add_deduct_on = false; self.confirm_send = false; - self.pin_visible = false; - // The PIN row is hidden in the DSL definition so a hot-reloaded or - // re-instantiated sheet re-applies it hidden; make it visible here. - self.view.view(cx, ids!(pin_visibility_row)).set_visible(cx, true); - self.view.text_input(cx, ids!(pin_input)).set_visible(cx, true); - self.view.button(cx, ids!(pin_eye_btn)).set_visible(cx, true); + #[cfg(feature = "demo")] + { + self.pin_visible = false; + // The DSL hides the PIN controls by default so a reload + // cannot surface them; a demo build opts back in here. + self.view.text_input(cx, ids!(pin_input)).set_visible(cx, true); + self.view.button(cx, ids!(pin_eye_btn)).set_visible(cx, true); + } + // Belt and braces. The PIN input is absent from the default DSL + // and `form_pin` does not exist to hold a value, so this only + // matters if a hot-reloaded UI definition reintroduces the widget. + #[cfg(not(feature = "demo"))] + { + self.view.text_input(cx, ids!(pin_input)).set_text(cx, ""); + } self.bulk_total = 0; self.bulk_done = 0; self.bulk_in_progress = false; self.cost_text = String::new(); - self.avoid_fees_split = false; - self.split_batch_count = 0; - self.recent_txns_visible = false; - self.recent_txns = Vec::new(); - self.reload_recent_txns(); - self.update_recent_header_visibility(cx); // Populate category picker if let Some(mut combo) = self.view.category_picker(cx, ids!(pay_category_combo)).borrow_mut() { @@ -2197,6 +1861,18 @@ impl SharedPaySheetRef { pub fn get_merchant(&self) -> String { self.borrow().map(|i| i.form_merchant.clone()).unwrap_or_default() } pub fn get_category(&self) -> String { self.borrow().map(|i| i.form_category.clone()).unwrap_or_default() } pub fn get_payment_method(&self) -> String { self.borrow().map(|i| i.form_payment_method.clone()).unwrap_or_default() } + /// Constructing a PIN-bearing legacy USSD request is prohibited outside + /// the explicit demo feature. Default builds must use the tracker/domain + /// path and never expose this secret-bearing value to parent widgets. + #[cfg(not(feature = "demo"))] + pub fn try_build_ussd_request(&self) -> Result { + // Not a runtime refusal: in a default build there is no `form_pin` + // field to read, so the PIN-bearing constructor below is not compiled + // at all (review item 0.3). + Err("USSD request construction is disabled in this build".into()) + } + + #[cfg(feature = "demo")] pub fn try_build_ussd_request(&self) -> Result { let request = self .borrow() @@ -2335,7 +2011,7 @@ script_mod! { sheet := RoundedView { visible: false - width: Fill, height: Fill + width: Fill, height: Fit flow: Down show_bg: true draw_bg +: { @@ -2423,7 +2099,7 @@ script_mod! { } body := ScrollYView { - width: Fill, height: Fill + width: Fill, height: Fit flow: Down spacing: 10 padding: Inset{left: 16, right: 16, top: 4, bottom: 20} @@ -2440,10 +2116,8 @@ script_mod! { merchant_input := mod.widgets.RobrixTextInput { width: Fill empty_text: "Merchant name" - draw_bg +: { color: #x00000000 } draw_text +: { color: (SHEET_FG) - color_focus: (SHEET_FG) color_empty: (SHEET_MUTED) text_style: theme.font_bold { font_size: 16.0 } } @@ -2473,7 +2147,6 @@ script_mod! { draw_bg +: { color: #x00000000 } draw_text +: { color: (SHEET_FG) - color_focus: (SHEET_FG) color_empty: (SHEET_MUTED) text_style: theme.font_bold { font_size: 22.0 } } @@ -2547,135 +2220,33 @@ script_mod! { } } - // ── Recent Transactions (Collapsible) ───────────────── - recent_header := View { - width: Fill, height: 36 - flow: Right - align: Align{x: 0.0, y: 0.5} - padding: Inset{left: 12, right: 12} + // Permission/accessibility toggles — each row: label | spacer | toggle + perm_row := RoundedView { + width: Fill, height: Fit + flow: Down, spacing: 8 + padding: Inset{left: 12, right: 12, top: 8, bottom: 8} visible: false show_bg: true draw_bg +: { - color: (SHEET_CARD_BG) - border_radius: 8.0 + color: #x2A1F0F + border_radius: 12.0 border_size: 1.0 - border_color: (SHEET_CARD_BORDER) + border_color: #x504020 } - cursor: MouseCursor.Hand - recent_arrow := Label { - text: "▶" - width: Fit, height: Fit - draw_text +: { - color: (SHEET_MUTED) - text_style: theme.font_regular { font_size: 10.0 } - } - } - recent_header_label := Label { - text: "Recent" + View { width: Fill, height: Fit - margin: Inset{left: 6} - draw_text +: { - color: (SHEET_FG) - text_style: theme.font_bold { font_size: 12.0 } - } + flow: Right, align: Align{y: 0.5} + Label { text: "📞 Phone Permission", draw_text +: { color: (SHEET_FG), text_style: theme.font_regular { font_size: 12.0 } } } + View { width: Fill } + phone_perm_toggle := ToggleFlat { height: Fill, margin: Inset{top: 2, bottom: 2} } } - recent_count_label := Label { - width: Fit, height: Fit - text: "" - draw_text +: { - color: (SHEET_MUTED) - text_style: theme.font_regular { font_size: 10.0 } - } - } - } - - // ── Recent Transactions List ────────────────────────── - recent_section := View { - width: Fill, height: Fit - flow: Down, spacing: 0 - visible: false - padding: Inset{left: 8, right: 8, top: 4, bottom: 4} - - recent_row_0 := View { width: Fill, height: Fit, flow: Down, cursor: MouseCursor.Hand - recent_row_0_content := View { width: Fill, height: Fit, flow: Right, align: Align{y: 0.5}, spacing: 8, padding: Inset{left: 8, right: 8, top: 6, bottom: 6} - recent_row_0_name := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - recent_row_0_info := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_MUTED), text_style: theme.font_regular { font_size: 10.0 } } } - recent_row_0_amount := Label { width: Fit, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - } - SolidView { width: Fill, height: 1, show_bg: true, draw_bg.color: (SHEET_CARD_BORDER) } - } - recent_row_1 := View { width: Fill, height: Fit, flow: Down, cursor: MouseCursor.Hand - recent_row_1_content := View { width: Fill, height: Fit, flow: Right, align: Align{y: 0.5}, spacing: 8, padding: Inset{left: 8, right: 8, top: 6, bottom: 6} - recent_row_1_name := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - recent_row_1_info := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_MUTED), text_style: theme.font_regular { font_size: 10.0 } } } - recent_row_1_amount := Label { width: Fit, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - } - SolidView { width: Fill, height: 1, show_bg: true, draw_bg.color: (SHEET_CARD_BORDER) } - } - recent_row_2 := View { width: Fill, height: Fit, flow: Down, cursor: MouseCursor.Hand - recent_row_2_content := View { width: Fill, height: Fit, flow: Right, align: Align{y: 0.5}, spacing: 8, padding: Inset{left: 8, right: 8, top: 6, bottom: 6} - recent_row_2_name := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - recent_row_2_info := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_MUTED), text_style: theme.font_regular { font_size: 10.0 } } } - recent_row_2_amount := Label { width: Fit, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - } - SolidView { width: Fill, height: 1, show_bg: true, draw_bg.color: (SHEET_CARD_BORDER) } - } - recent_row_3 := View { width: Fill, height: Fit, flow: Down, cursor: MouseCursor.Hand - recent_row_3_content := View { width: Fill, height: Fit, flow: Right, align: Align{y: 0.5}, spacing: 8, padding: Inset{left: 8, right: 8, top: 6, bottom: 6} - recent_row_3_name := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - recent_row_3_info := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_MUTED), text_style: theme.font_regular { font_size: 10.0 } } } - recent_row_3_amount := Label { width: Fit, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - } - SolidView { width: Fill, height: 1, show_bg: true, draw_bg.color: (SHEET_CARD_BORDER) } - } - recent_row_4 := View { width: Fill, height: Fit, flow: Down, cursor: MouseCursor.Hand - recent_row_4_content := View { width: Fill, height: Fit, flow: Right, align: Align{y: 0.5}, spacing: 8, padding: Inset{left: 8, right: 8, top: 6, bottom: 6} - recent_row_4_name := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - recent_row_4_info := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_MUTED), text_style: theme.font_regular { font_size: 10.0 } } } - recent_row_4_amount := Label { width: Fit, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - } - SolidView { width: Fill, height: 1, show_bg: true, draw_bg.color: (SHEET_CARD_BORDER) } - } - recent_row_5 := View { width: Fill, height: Fit, flow: Down, cursor: MouseCursor.Hand - recent_row_5_content := View { width: Fill, height: Fit, flow: Right, align: Align{y: 0.5}, spacing: 8, padding: Inset{left: 8, right: 8, top: 6, bottom: 6} - recent_row_5_name := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - recent_row_5_info := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_MUTED), text_style: theme.font_regular { font_size: 10.0 } } } - recent_row_5_amount := Label { width: Fit, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - } - SolidView { width: Fill, height: 1, show_bg: true, draw_bg.color: (SHEET_CARD_BORDER) } - } - recent_row_6 := View { width: Fill, height: Fit, flow: Down, cursor: MouseCursor.Hand - recent_row_6_content := View { width: Fill, height: Fit, flow: Right, align: Align{y: 0.5}, spacing: 8, padding: Inset{left: 8, right: 8, top: 6, bottom: 6} - recent_row_6_name := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - recent_row_6_info := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_MUTED), text_style: theme.font_regular { font_size: 10.0 } } } - recent_row_6_amount := Label { width: Fit, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - } - SolidView { width: Fill, height: 1, show_bg: true, draw_bg.color: (SHEET_CARD_BORDER) } - } - recent_row_7 := View { width: Fill, height: Fit, flow: Down, cursor: MouseCursor.Hand - recent_row_7_content := View { width: Fill, height: Fit, flow: Right, align: Align{y: 0.5}, spacing: 8, padding: Inset{left: 8, right: 8, top: 6, bottom: 6} - recent_row_7_name := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - recent_row_7_info := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_MUTED), text_style: theme.font_regular { font_size: 10.0 } } } - recent_row_7_amount := Label { width: Fit, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - } - SolidView { width: Fill, height: 1, show_bg: true, draw_bg.color: (SHEET_CARD_BORDER) } - } - recent_row_8 := View { width: Fill, height: Fit, flow: Down, cursor: MouseCursor.Hand - recent_row_8_content := View { width: Fill, height: Fit, flow: Right, align: Align{y: 0.5}, spacing: 8, padding: Inset{left: 8, right: 8, top: 6, bottom: 6} - recent_row_8_name := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - recent_row_8_info := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_MUTED), text_style: theme.font_regular { font_size: 10.0 } } } - recent_row_8_amount := Label { width: Fit, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - } - SolidView { width: Fill, height: 1, show_bg: true, draw_bg.color: (SHEET_CARD_BORDER) } - } - recent_row_9 := View { width: Fill, height: Fit, flow: Down, cursor: MouseCursor.Hand - recent_row_9_content := View { width: Fill, height: Fit, flow: Right, align: Align{y: 0.5}, spacing: 8, padding: Inset{left: 8, right: 8, top: 6, bottom: 6} - recent_row_9_name := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - recent_row_9_info := Label { width: Fill, height: Fit, text: "" draw_text +: { color: (SHEET_MUTED), text_style: theme.font_regular { font_size: 10.0 } } } - recent_row_9_amount := Label { width: Fit, height: Fit, text: "" draw_text +: { color: (SHEET_FG), text_style: theme.font_bold { font_size: 11.0 } } } - } - SolidView { width: Fill, height: 1, show_bg: true, draw_bg.color: (SHEET_CARD_BORDER) } + View { + width: Fill, height: Fit + flow: Right, align: Align{y: 0.5} + Label { text: "♿ Accessibility", draw_text +: { color: (SHEET_FG), text_style: theme.font_regular { font_size: 12.0 } } } + View { width: Fill } + ussd_toggle := ToggleFlat { height: Fit, margin: Inset{top: 2, bottom: 2} } } } @@ -2704,7 +2275,6 @@ script_mod! { draw_bg +: { color: #x00000000 } draw_text +: { color: (SHEET_FG) - color_focus: (SHEET_FG) color_empty: (SHEET_MUTED) text_style: theme.font_bold { font_size: 22.0 } } @@ -2718,7 +2288,6 @@ script_mod! { phone_input := mod.widgets.RobrixTextInput { width: Fill empty_text: "Phone (07XXXXXXXX)" - draw_bg +: { color: #x00000000 } draw_text +: { color: (SHEET_FG) color_hover: (SHEET_FG) @@ -2727,21 +2296,6 @@ script_mod! { color_empty_hover: (SHEET_MUTED) } } - phone_clear_btn := Button { - width: Fit, height: 36 - text: "✕" - padding: Inset{left: 6, right: 6} - draw_bg +: { - color: #x2A2F31 - color_hover: #x33383A - color_down: #x222628 - border_radius: 8.0 - } - draw_text +: { - color: (SHEET_MUTED) - text_style: theme.font_bold { font_size: 11.0 } - } - } csv_file_btn := Button { width: Fit, height: 36 text: "📂 Select CSV" @@ -2783,8 +2337,7 @@ script_mod! { till_input := mod.widgets.RobrixTextInput { width: Fill empty_text: "Till / Buy-Goods number" - draw_bg +: { color: #x00000000 } - draw_text +: { color: (SHEET_FG), color_focus: (SHEET_FG), color_empty: (SHEET_MUTED) } + draw_text +: { color: (SHEET_FG), color_empty: (SHEET_MUTED) } } } paybill_row := View { @@ -2794,14 +2347,12 @@ script_mod! { business_input := mod.widgets.RobrixTextInput { width: Fill empty_text: "Business number" - draw_bg +: { color: #x00000000 } - draw_text +: { color: (SHEET_FG), color_focus: (SHEET_FG), color_empty: (SHEET_MUTED) } + draw_text +: { color: (SHEET_FG), color_empty: (SHEET_MUTED) } } account_input := mod.widgets.RobrixTextInput { width: Fill empty_text: "Account reference" - draw_bg +: { color: #x00000000 } - draw_text +: { color: (SHEET_FG), color_focus: (SHEET_FG), color_empty: (SHEET_MUTED) } + draw_text +: { color: (SHEET_FG), color_empty: (SHEET_MUTED) } } } withdraw_row := View { @@ -2811,14 +2362,12 @@ script_mod! { agent_input := mod.widgets.RobrixTextInput { width: Fill empty_text: "Agent code" - draw_bg +: { color: #x00000000 } - draw_text +: { color: (SHEET_FG), color_focus: (SHEET_FG), color_empty: (SHEET_MUTED) } + draw_text +: { color: (SHEET_FG), color_empty: (SHEET_MUTED) } } store_input := mod.widgets.RobrixTextInput { width: Fill empty_text: "Store / merchant number" - draw_bg +: { color: #x00000000 } - draw_text +: { color: (SHEET_FG), color_focus: (SHEET_FG), color_empty: (SHEET_MUTED) } + draw_text +: { color: (SHEET_FG), color_empty: (SHEET_MUTED) } } } @@ -2826,7 +2375,28 @@ script_mod! { width: Fill, height: Fit flow: Right, spacing: 6 align: Align{y: 0.5} + // Review item 0.3 / defect B11. Hidden in + // the UI definition itself, not only by a + // runtime call in `on_after_new`: a + // hot-reloaded or re-instantiated sheet + // re-applies the DSL and would otherwise + // bring the PIN field back on screen. + // `demo` builds unhide it on init. + // + // The flag lives on this ROW, not on the + // children. 69f6fb2 removed `visible: + // false` from `pin_eye_btn` because + // Button has no such DSL property and it + // logged "property visible not defined on + // type" every frame -- but the same commit + // took the line off `pin_input` too, and + // for eight days both PIN controls were + // visible by default. View does support + // `visible`, so hiding the container is + // both correct and free of that warning. + visible: false pin_input := mod.widgets.RobrixTextInput { + visible: false width: Fill empty_text: "M-Pesa PIN" is_password: true @@ -2839,6 +2409,7 @@ script_mod! { } } pin_eye_btn := Button { + visible: false width: 36, height: 36 text: "👁" draw_bg +: { color: #x00000000, border_radius: 18.0 } @@ -2891,34 +2462,17 @@ script_mod! { } } - // ── Avoid Fees Split Row ──────────────────────────────── - split_fees_row := View { - width: Fill, height: Fit - flow: Right, spacing: 6 - align: Align{y: 0.5} - split_fees_checkbox := CheckBoxFlat { - height: Fit, text: "Avoid fees (split ≤100)" - draw_text +: { text_style: theme.font_regular { font_size: 11.0 } } - } - View { width: Fill } - split_fees_info := Label { - width: Fit, text: "" - draw_text +: { - color: (SHEET_MUTED) - text_style: theme.font_regular { font_size: 10.0 } - } - } - } - - // ── Bulk Pay Row ──────────────────────────────────────── + // ── Bulk Toggle Row ──────────────────────────────────── bulk_toggle_row := View { width: Fill, height: Fit flow: Right, spacing: 8 align: Align{y: 0.5} - bulk_toggle := CheckBoxFlat { - height: Fit, text: "Bulk Pay" - draw_text +: { text_style: theme.font_regular { font_size: 12.0 } } + Label { + text: "📋 Bulk Pay" + draw_text +: { color: (SHEET_FG), text_style: theme.font_regular { font_size: 12.0 } } } + View { width: Fill } + bulk_toggle := ToggleFlat { height: Fit, margin: Inset{top: 2, bottom: 2} } } // ── Bulk section (visible when bulk mode is on) ──────── @@ -2927,44 +2481,6 @@ script_mod! { flow: Down, spacing: 8 visible: false - // CSV file picker row - bulk_csv_row := View { - width: Fill, height: Fit - flow: Right, spacing: 6 - align: Align{y: 0.5} - bulk_csv_file_btn := Button { - width: Fill, height: 36 - text: "📂 Select CSV" - padding: Inset{left: 10, right: 10} - draw_bg +: { - color: #x2A2F31 - color_hover: #x33383A - color_down: #x222628 - border_radius: 8.0 - } - draw_text +: { - color: (SHEET_FG) - text_style: theme.font_bold { font_size: 11.0 } - } - } - bulk_csv_preview_icon := Button { - width: 28, height: 28 - visible: false - text: "👁" - draw_bg +: { color: #x00000000, border_radius: 14.0 } - draw_text +: { color: (SHEET_MUTED), text_style: theme.font_regular { font_size: 12.0 } } - } - bulk_pick_contact_btn := Button { - width: 36, height: 36 - text: "📇" - draw_bg +: { color: #x00000000, border_radius: 18.0 } - draw_text +: { - color: (SHEET_FG) - text_style: theme.font_regular { font_size: 16.0 } - } - } - } - cost_label := Label { width: Fill text: "" diff --git a/crates/apps/nigig-pay/Cargo.toml b/crates/apps/nigig-pay/Cargo.toml index 72b8291..ecb84a0 100644 --- a/crates/apps/nigig-pay/Cargo.toml +++ b/crates/apps/nigig-pay/Cargo.toml @@ -5,10 +5,40 @@ version = "0.1.0" edition = "2021" [features] -default = [] +## USSD automation is ON by default. +## +## Review item 0.1 asked for dispatch behind a compile-time flag. That was +## implemented as default-off, which made the *334# automation — the app's +## primary function — unreachable in an ordinary build, and unreachable at +## all from the APK crate until 9f0e133. +## +## The containment requirement has not been dropped; it has moved to release +## packaging, which is where a shipping decision belongs. A Play-listed build +## must be produced with `--no-default-features` (plus the features it does +## want), and the unresolved Google Play policy risk is recorded in +## REVIEWS/adr/0007-payment-platform-boundary.md. +## +## Turning it off for a build: +## cargo build -p pageflipnav --no-default-features --features native +default = ["demo"] +## Forwards to `nigig-pay-ui/demo`, which enables USSD dispatch, auto-retry +## and bulk pay. +## +## Off by default on purpose (review items 0.1/0.3): the default build is a +## tracker that never dials USSD and never handles an M-Pesa PIN. Without +## this feature the Pay sheet reports that dispatch is unavailable, which is +## the intended behaviour rather than a build error. +## +## Enabling it turns on AccessibilityService-driven USSD automation. That +## remains an unresolved Google Play policy risk (review item 5.2, +## REVIEWS/adr/0007) and must not be enabled for a shipped build. +## +## USSD only has a real backend on Android; every other target returns +## `PermanentlyUnavailable`, so this feature does nothing useful on desktop. +demo = ["nigig-pay-ui/demo"] [dependencies] -makepad-widgets = { workspace = true, features = ["test"] } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc", features = ["test"] } nigig-core = { path = "../../nigig-core" } # The exchange tab's spread analysis. p2p-intel is a nested workspace that # the root excludes, but a path dependency into it resolves fine and keeps diff --git a/crates/apps/nigig-pay/src/payments_frame/pages/mpesa/expenses/mod.rs b/crates/apps/nigig-pay/src/payments_frame/pages/mpesa/expenses/mod.rs index b24d4c3..f4be553 100644 --- a/crates/apps/nigig-pay/src/payments_frame/pages/mpesa/expenses/mod.rs +++ b/crates/apps/nigig-pay/src/payments_frame/pages/mpesa/expenses/mod.rs @@ -6,27 +6,6 @@ use super::transactions::classifier::{MpesaClassifier, TransactionCategory}; use super::transactions::parser::{self as mpesa_parser, MpesaTransaction, TransactionType}; use super::transactions::store::MpesaTransactionStore; -// ── Action emitted by ExpenseRow when edit/save/cancel buttons are tapped ── -#[derive(Clone, Debug, Default)] -pub enum ExpenseRowAction { - #[default] - None, - Edit, - Cancel, - Save, -} - -impl ActionDefaultRef for ExpenseRowAction { - fn default_ref() -> &'static Self { - static DEFAULT: ExpenseRowAction = ExpenseRowAction::None; - &DEFAULT - } -} - -// Scope props passed to each ExpenseRow via PortalList to indicate editing state. -struct ExpenseRowScopeProps { - is_editing: bool, -} script_mod! { use mod.prelude.widgets.* @@ -35,10 +14,6 @@ script_mod! { let EXP_TEXT = #x2B2B2D let EXP_MUTED = #x8E8E93 let EXP_LINE = #xE5E5E5 - let EXP_CARD_BG = #xF8F9FA - let EXP_CARD_BORDER = #xDEE2E6 - let EXP_INPUT_BG = #xFFFFFF - let EXP_GREEN = #x138808 mod.widgets.MpesaExpenseHeaderCell = View { width: Fill, height: 42 @@ -49,17 +24,15 @@ script_mod! { label := Label { width: Fit, height: Fit, text: "Column" draw_text +: { color: (EXP_MUTED), text_style: theme.font_bold { font_size: 15.0 } } } } - mod.widgets.ExpenseReadOnlyRow = View { - width: Fill, height: Fit + mod.widgets.MpesaExpenseRow = SolidView { + width: Fill, height: 62 flow: Down show_bg: false row := View { - width: Fill, height: Fit + width: Fill, height: Fill flow: Right align: Align{y: 0.5} - spacing: 8 - padding: Inset{left: 8, right: 8, top: 8, bottom: 8} expense := Label { width: Fill, height: Fit @@ -76,10 +49,9 @@ script_mod! { text: "$ 1200" draw_text +: { color: #x5A5A60, text_style: theme.font_regular { font_size: 15.0 } } } - edit_btn := Button { - width: 34, height: 30 + edit := Label { + width: 34, height: Fit text: "✎" - draw_bg +: { color: #x00000000, color_hover: #xE5E5E5, color_down: #xDEE2E6, border_radius: 8.0 } draw_text +: { color: #xA1A1AA, text_style: theme.font_bold { font_size: 18.0 } } } } @@ -87,70 +59,6 @@ script_mod! { line := SolidView { width: Fill, height: 1, show_bg: true, draw_bg.color: (EXP_LINE) } } - mod.widgets.ExpenseEditForm = View { - width: Fill, height: Fit - flow: Down - spacing: 8 - padding: Inset{left: 8, right: 8, top: 10, bottom: 10} - show_bg: true - draw_bg +: { color: (EXP_CARD_BG), border_radius: 10.0, border_size: 1.0, border_color: (EXP_CARD_BORDER) } - - View { - width: Fill, height: Fit - flow: Right, spacing: 8, align: Align{y: 0.5} - Label { text: "Expense" width: Fit, height: Fit, draw_text +: { color: (EXP_MUTED), text_style: theme.font_bold { font_size: 12.0 } } } - edit_expense_input := TextInput { - width: Fill, height: 36 - text: "" - empty_text: "Category" - draw_bg +: { color: (EXP_INPUT_BG), border_size: 1.0, border_color: (EXP_CARD_BORDER), border_radius: 6.0 } - draw_text +: { color: (EXP_TEXT), text_style: theme.font_regular { font_size: 14.0 } } - } - } - View { - width: Fill, height: Fit - flow: Right, spacing: 8, align: Align{y: 0.5} - Label { text: "Amount" width: Fit, height: Fit, draw_text +: { color: (EXP_MUTED), text_style: theme.font_bold { font_size: 12.0 } } } - edit_amount_input := TextInput { - width: Fill, height: 36 - text: "" - empty_text: "0" - draw_bg +: { color: (EXP_INPUT_BG), border_size: 1.0, border_color: (EXP_CARD_BORDER), border_radius: 6.0 } - draw_text +: { color: (EXP_TEXT), text_style: theme.font_regular { font_size: 14.0 } } - } - } - View { - width: Fill, height: Fit - flow: Right, spacing: 8, align: Align{y: 0.5} - View { width: Fill } - cancel_edit_btn := Button { - width: Fit, height: 34, text: "Cancel" - padding: Inset{left: 14, right: 14} - draw_bg +: { color: (EXP_LINE), border_radius: 8.0 } - draw_text +: { color: (EXP_TEXT), text_style: theme.font_bold { font_size: 12.0 } } - } - save_edit_btn := Button { - width: Fit, height: 34, text: "Save" - padding: Inset{left: 14, right: 14} - draw_bg +: { color: (EXP_GREEN), border_radius: 8.0 } - draw_text +: { color: #xFFFFFF, text_style: theme.font_bold { font_size: 12.0 } } - } - } - } - - mod.widgets.ExpenseRowBase = #(ExpenseRow::register_widget(vm)) - - mod.widgets.ExpenseRow = set_type_default() do mod.widgets.ExpenseRowBase { - width: Fill, height: Fit - flip := PageFlip { - width: Fill, height: Fit - lazy_init: true - active_page: @read_page - read_page := mod.widgets.ExpenseReadOnlyRow {} - edit_page := mod.widgets.ExpenseEditForm { visible: false } - } - } - mod.widgets.MpesaExpensesPage = #(MpesaExpensesPage::register_widget(vm)) { width: Fill, height: Fill flow: Down @@ -173,7 +81,7 @@ script_mod! { width: Fill, height: Fill flow: Down spacing: 0 - expense_row := mod.widgets.ExpenseRow {} + expense_row := mod.widgets.MpesaExpenseRow {} empty_state := View { width: Fill, height: 420 flow: Down @@ -212,75 +120,16 @@ script_mod! { } } -// ── ExpenseRow widget: a proper Widget so PortalList button clicks work ──── - -#[derive(Script, ScriptHook, Widget)] -pub struct ExpenseRow { - #[deref] - view: View, - #[rust] - editing: bool, -} - -impl Widget for ExpenseRow { - fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - let uid = self.widget_uid(); - - if let Event::Actions(_) = event { - let child_actions = cx.capture_actions(|cx| { - self.view.handle_event(cx, event, scope); - }); - - if self.view.button(cx, ids!(flip.read_page.row.edit_btn)).clicked(&child_actions) { - cx.widget_action(uid, ExpenseRowAction::Edit); - } - if self.view.button(cx, ids!(flip.edit_page.cancel_edit_btn)).clicked(&child_actions) { - cx.widget_action(uid, ExpenseRowAction::Cancel); - } - if self.view.button(cx, ids!(flip.edit_page.save_edit_btn)).clicked(&child_actions) { - cx.widget_action(uid, ExpenseRowAction::Save); - } - } else { - self.view.handle_event(cx, event, scope); - } - - // Toggle PageFlip based on editing state set during draw_walk - let active_page = if self.editing { - live_id!(edit_page) - } else { - live_id!(read_page) - }; - self.view - .page_flip(cx, ids!(flip)) - .set_active_page(cx, active_page); - } - - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - self.editing = scope - .props - .get::() - .map(|p| p.is_editing) - .unwrap_or(false); - self.view.draw_walk(cx, scope, walk) - } -} - -// ── MpesaExpensesPage ────────────────────────────────────────────────────── - #[derive(Script, Widget)] pub struct MpesaExpensesPage { #[deref] view: View, #[rust] expenses: Vec, + /// Set when the cached list is known to be stale. Consumed in + /// `handle_event` so no disk read happens during a draw. #[rust] needs_reload: bool, - #[rust] - editing_index: Option, - #[rust] - edit_expense_text: String, - #[rust] - edit_amount_text: String, } impl ScriptHook for MpesaExpensesPage { @@ -293,66 +142,20 @@ impl ScriptHook for MpesaExpensesPage { impl Widget for MpesaExpensesPage { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + // Any pending reload happens here, off the draw path. self.reload_if_stale(cx); let actions = cx.capture_actions(|cx| self.view.handle_event(cx, event, scope)); if let Event::Actions(_) = event { - // ── ExpenseRow widget actions (edit / save / cancel) ──────────── - let list = self.view.portal_list(cx, ids!(expense_list)); - let items_with_actions: Vec = list - .items_with_actions(&actions) - .into_iter() - .map(|(id, _)| id) - .collect(); - - for action in &actions { - match action.as_widget_action().cast() { - ExpenseRowAction::Edit => { - if let Some(&idx) = items_with_actions.first() { - self.editing_index = Some(idx); - if let Some(tx) = self.expenses.get(idx) { - self.edit_expense_text = expense_name(tx); - self.edit_amount_text = - mpesa_parser::format_amount(tx.amount).to_string(); - } - self.view.redraw(cx); - } - } - ExpenseRowAction::Cancel => { - self.editing_index = None; - self.edit_expense_text.clear(); - self.edit_amount_text.clear(); - self.view.redraw(cx); - } - ExpenseRowAction::Save => { - if let Some(idx) = self.editing_index { - if idx < self.expenses.len() { - let tx = &mut self.expenses[idx]; - if !self.edit_expense_text.is_empty() { - tx.classification.sub_category = - self.edit_expense_text.clone(); - } - if let Ok(amount) = self.edit_amount_text.parse::() { - tx.amount = amount; - } - } - } - self.editing_index = None; - self.edit_expense_text.clear(); - self.edit_amount_text.clear(); - self.view.redraw(cx); - } - _ => {} - } - } - - // ── Non-row buttons ───────────────────────────────────────────── + // ── NEW: Add Expense button → emit request to parent ── if self.view.button(cx, ids!(add_expense_btn)).clicked(&actions) { cx.widget_action(self.widget_uid(), PaySheetRequest::OpenNewExpense); } - if self.view.button(cx, ids!(add_category_btn)).clicked(&actions) - || self.view.button(cx, ids!(categories_btn)).clicked(&actions) + + // Existing: category buttons just reload + if self.view.button(cx, ids!(add_category_btn)).clicked(&actions) || + self.view.button(cx, ids!(categories_btn)).clicked(&actions) { self.reload(cx); self.view.redraw(cx); @@ -363,6 +166,11 @@ impl Widget for MpesaExpensesPage { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + // Review item 4.1: `reload()` opens and parses the whole transaction + // store from disk. Calling it here ran that read on every frame, so + // scrolling this page re-read the entire ledger continuously. + // Data is now loaded once in `on_after_new` and refreshed only on an + // explicit user action or an external change notification. while let Some(step) = self.view.draw_walk(cx, scope, walk).step() { if let Some(mut list) = step.borrow_mut::() { let total = self.expenses.len().max(1); @@ -370,35 +178,14 @@ impl Widget for MpesaExpensesPage { while let Some(item_id) = list.next_visible_item(cx) { let mut item_scope = Scope::empty(); if self.expenses.is_empty() { - list.item(cx.cx, item_id, id!(empty_state)) - .draw_all(cx, &mut item_scope); + list.item(cx.cx, item_id, id!(empty_state)).draw_all(cx, &mut item_scope); continue; } - let Some(tx) = self.expenses.get(item_id) else { - continue; - }; + let Some(tx) = self.expenses.get(item_id) else { continue; }; let item = list.item(cx.cx, item_id, id!(expense_row)); - - let is_editing = self.editing_index == Some(item_id); - let row_props = ExpenseRowScopeProps { is_editing }; - item_scope = Scope::with_props(&row_props); - - if is_editing { - item.text_input(cx.cx, ids!(flip.edit_page.edit_expense_input)) - .set_text(cx.cx, &self.edit_expense_text); - item.text_input(cx.cx, ids!(flip.edit_page.edit_amount_input)) - .set_text(cx.cx, &self.edit_amount_text); - } else { - item.label(cx.cx, ids!(flip.read_page.row.expense)) - .set_text(cx.cx, &expense_name(tx)); - item.label(cx.cx, ids!(flip.read_page.row.method)) - .set_text(cx.cx, method_name(tx)); - item.label(cx.cx, ids!(flip.read_page.row.amount)).set_text( - cx.cx, - &format!("Ksh {}", mpesa_parser::format_amount(tx.amount)), - ); - } - + item.label(cx.cx, ids!(row.expense)).set_text(cx.cx, &expense_name(tx)); + item.label(cx.cx, ids!(row.method)).set_text(cx.cx, method_name(tx)); + item.label(cx.cx, ids!(row.amount)).set_text(cx.cx, &format!("Ksh {}", mpesa_parser::format_amount(tx.amount))); item.draw_all(cx, &mut item_scope); } } @@ -408,10 +195,15 @@ impl Widget for MpesaExpensesPage { } impl MpesaExpensesPage { + /// Mark the cached list stale. Called when another page changes the + /// underlying store; the reload happens on the next event, never inside + /// `draw_walk`. pub fn invalidate(&mut self) { self.needs_reload = true; } + /// Reload if something invalidated the cache. Safe to call from + /// `handle_event`; must not be called from `draw_walk`. fn reload_if_stale(&mut self, cx: &mut Cx) { if self.needs_reload { self.needs_reload = false; @@ -447,3 +239,5 @@ fn method_name(tx: &MpesaTransaction) -> &'static str { _ => "M-Pesa", } } + + diff --git a/crates/apps/nigig-pay/src/payments_frame/pages/mpesa/transactions/transact.rs b/crates/apps/nigig-pay/src/payments_frame/pages/mpesa/transactions/transact.rs index f697bcc..b3b535d 100644 --- a/crates/apps/nigig-pay/src/payments_frame/pages/mpesa/transactions/transact.rs +++ b/crates/apps/nigig-pay/src/payments_frame/pages/mpesa/transactions/transact.rs @@ -6,8 +6,6 @@ use crate::shared::camera_widget::{CameraMode, CameraWidgetAction, CameraWidgetW use chrono::{Datelike, Duration, Local, Months, NaiveDate, TimeZone}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{LazyLock, Mutex}; -use std::thread; /// How often the inbox is rescanned for new M-Pesa messages. /// @@ -32,17 +30,6 @@ use super::transaction_content_detail::TransactionDetailContent; static MPESA_SMS_PERMISSION_REQUESTED: AtomicBool = AtomicBool::new(false); -// ─── Async SMS fetch worker ──────────────────────────────────────────────── -// robius_sms::list_messages() is a blocking JNI ContentProvider query over -// Binder that can take hundreds of milliseconds on a large inbox. It is safe -// to call off the UI thread because with_activity() uses -// attach_current_thread_permanently() and ContentResolver.query is -// thread-safe. Worker thread → result queue → SignalToUI → drain on UI thread. -#[cfg(target_os = "android")] -static MPESA_SMS_FETCH_IN_FLIGHT: AtomicBool = AtomicBool::new(false); -static PENDING_MPESA_SMS_FETCH: LazyLock, String>>>> = - LazyLock::new(|| Mutex::new(None)); - script_mod! { use mod.prelude.widgets.* use mod.widgets.* @@ -834,11 +821,6 @@ impl ScriptHook for PayMpesaPage { impl Widget for PayMpesaPage { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - // Drain any completed background SMS fetch before processing events. - if let Event::Actions(_) = event { - self.drain_pending_sms_fetch(cx); - } - // Handle Startup — one-time init if let Event::Startup = event { self.init_subsystems(); @@ -849,38 +831,10 @@ impl Widget for PayMpesaPage { self.auto_scan_timer = cx.start_interval(AUTO_SCAN_INTERVAL_SECONDS); } - // Handle Resume — register the SMS ContentObserver so incoming - // messages push a signal instead of relying solely on the 8-second - // polling timer. Idempotent; re-arms after the OS tears the - // observer down on app background. - if let Event::Resume = event { - #[cfg(target_os = "android")] - { - robius_sms::set_sms_change_waker(SignalToUI::set_ui_signal); - if let Err(e) = robius_sms::register_sms_observer() { - log!("[M-Pesa] could not register inbox observer: {:?}", e); - } - } - self.scan_sms(cx); - } + // Handle Resume + if let Event::Resume = event { self.scan_sms(cx); } - // Signal from background worker or SMS ContentObserver. - // The worker thread signals via SignalToUI after writing results - // to PENDING_MPESA_SMS_FETCH. The ContentObserver signals when - // the SMS provider table changes (new message, read status, etc.). - if matches!(event, Event::Signal) { - self.drain_pending_sms_fetch(cx); - - // D2: the provider reported a change — refresh immediately - // instead of waiting for the next 8-second timer tick. - #[cfg(target_os = "android")] - if robius_sms::take_sms_changed() { - self.scan_sms(cx); - } - } - - // Periodic SMS scan, off the draw path — fallback for platforms - // without the ContentObserver (or if the observer was torn down). + // Periodic SMS scan, off the draw path. if self.auto_scan_timer.is_event(event).is_some() { self.last_auto_scan_at = cx.seconds_since_app_start(); self.scan_sms(cx); @@ -1219,13 +1173,6 @@ impl PayMpesaPage { store.merge_transactions(vec![tx]); } - /// Kick off an async SMS read on a worker thread. - /// - /// On Android the blocking `robius_sms::list_messages()` JNI call runs on - /// a background thread. The result lands in `PENDING_MPESA_SMS_FETCH` and - /// is consumed by `drain_pending_sms_fetch` on the UI thread after - /// `SignalToUI` wakes the event loop. This mirrors the D1 pattern in - /// nigig-sms's `ConversationsList`. fn scan_sms(&mut self, cx: &mut Cx) { self.status_text = "Scanning SMS inbox…".to_string(); @@ -1233,31 +1180,36 @@ impl PayMpesaPage { { use robius_sms::{has_permission, request_permissions, Permission}; match has_permission(Permission::ReadSms) { - Ok(true) => { - if MPESA_SMS_FETCH_IN_FLIGHT.swap(true, Ordering::Relaxed) { + Ok(true) => match robius_sms::list_messages() { + Ok(messages) => { + let mut parsed = Vec::new(); + for msg in &messages { + let addr = msg.address.clone().unwrap_or_default(); + let body = msg.body.clone().unwrap_or_default(); + let date = msg.date_ms.unwrap_or(0); + if let Some(mut tx) = mpesa_parser::parse(&addr, &body, date) { + self.apply_user_category_match(&mut tx); + parsed.push(tx); + } + } + offline_store::upsert_sms_messages(messages.iter().map(|msg| OfflineSmsMessage { + address: msg.address.clone().unwrap_or_default(), + body: msg.body.clone().unwrap_or_default(), + date_ms: msg.date_ms.unwrap_or(0), + kind: format!("{:?}", msg.kind).to_lowercase(), + is_read: msg.read.unwrap_or(true), + })); + let added = self.store.merge_transactions(parsed); + self.status_text = format!("Read {} SMS • {added} new", messages.len()); + self.refresh_filtered(cx); + self.update_summary(cx); + self.view.redraw(cx); return; } - thread::spawn(move || { - let result = match robius_sms::list_messages() { - Ok(msgs) => { - offline_store::upsert_sms_messages(msgs.iter().map(|msg| { - OfflineSmsMessage { - address: msg.address.clone().unwrap_or_default(), - body: msg.body.clone().unwrap_or_default(), - date_ms: msg.date_ms.unwrap_or(0), - kind: format!("{:?}", msg.kind).to_lowercase(), - is_read: msg.read.unwrap_or(true), - } - })); - Ok(msgs) - } - Err(e) => Err(format!("Failed to read SMS: {e}")), - }; - *PENDING_MPESA_SMS_FETCH.lock().unwrap() = Some(result); - MPESA_SMS_FETCH_IN_FLIGHT.store(false, Ordering::Relaxed); - SignalToUI::set_ui_signal(); - }); - } + Err(e) => { + self.status_text = format!("Failed to read SMS: {:?}; using cached SMS", e); + } + }, Ok(false) => { if !MPESA_SMS_PERMISSION_REQUESTED.swap(true, Ordering::Relaxed) { let _ = request_permissions(&[Permission::ReadSms]); @@ -1270,13 +1222,9 @@ impl PayMpesaPage { self.status_text = format!("SMS permission error: {:?}; using cached SMS", e); } } - self.refresh_filtered(cx); - self.update_summary(cx); - self.view.redraw(cx); - return; } - #[cfg(not(target_os = "android"))] + #[cfg(all(not(target_os = "android"), feature = "demo"))] { let dummy = robius_sms::dummy_messages(); let mut parsed = Vec::new(); @@ -1304,46 +1252,16 @@ impl PayMpesaPage { return; } + #[cfg(all(not(target_os = "android"), not(feature = "demo")))] + { + self.status_text = "Desktop: SMS scanning disabled (compile with `--features demo`)".to_string(); + } + self.refresh_filtered(cx); self.update_summary(cx); self.view.redraw(cx); } - /// Drain a completed background SMS fetch (runs on the UI thread). - /// - /// Parses the raw messages into M-Pesa transactions and merges them into - /// the store. Returns `true` if the transaction list actually changed. - fn drain_pending_sms_fetch(&mut self, cx: &mut Cx) -> bool { - let Some(result) = PENDING_MPESA_SMS_FETCH.lock().unwrap().take() else { - return false; - }; - match result { - Ok(messages) => { - let mut parsed = Vec::new(); - for msg in &messages { - let addr = msg.address.clone().unwrap_or_default(); - let body = msg.body.clone().unwrap_or_default(); - let date = msg.date_ms.unwrap_or(0); - if let Some(mut tx) = mpesa_parser::parse(&addr, &body, date) { - self.apply_user_category_match(&mut tx); - parsed.push(tx); - } - } - let added = self.store.merge_transactions(parsed); - self.status_text = format!("Read {} SMS • {added} new", messages.len()); - self.refresh_filtered(cx); - self.update_summary(cx); - self.view.redraw(cx); - added > 0 - } - Err(msg) => { - self.status_text = msg; - self.view.redraw(cx); - false - } - } - } - fn update_summary(&mut self, cx: &mut Cx) { use nigig_pay_domain::{Direction, Money, PeriodSummary, SummaryEntry}; diff --git a/crates/apps/nigig-pay/src/payments_frame/payments.rs b/crates/apps/nigig-pay/src/payments_frame/payments.rs index 6044d8e..068c91d 100644 --- a/crates/apps/nigig-pay/src/payments_frame/payments.rs +++ b/crates/apps/nigig-pay/src/payments_frame/payments.rs @@ -13,7 +13,6 @@ use crate::features::action_page_navigation::ActionPageNavigationAction; use makepad_widgets::*; -use std::time::Instant; use crate::pay_sheet_request::{PaySheetRequest, PaySheetRequestExt}; use nigig_pay_ui::shared_pay_sheet::FingerprintScanFeedback as Fb; @@ -33,7 +32,6 @@ script_mod! { } pay_action_page_flip := PageFlip { - lazy_init: true width: Fill, height: Fill active_page: @send_page @@ -55,9 +53,7 @@ script_mod! { } } - pay_sheet := CachedWidget { - mod.widgets.SharedPaySheet {} - } + pay_sheet := mod.widgets.SharedPaySheet {} } } @@ -67,8 +63,6 @@ pub struct PaymentsScreen { view: View, #[rust] subsystems_inited: bool, - #[rust] - pay_sheet_opened: bool, // Drives pay_flow::tick() at 100 ms while a payment is in flight. // Biometric + USSD results arrive on native callbacks that do NOT wake // Makepad's event loop, so without this timer the banner only updates on @@ -80,19 +74,10 @@ pub struct PaymentsScreen { impl Widget for PaymentsScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - let t0 = Instant::now(); // ── One-time init ──────────────────────────────────────────────────── if !self.subsystems_inited { - log!("[TIMING] PaymentsScreen::handle_event: subsystems_inited start"); let _ = robius_ussd::init(); - log!("[TIMING] PaymentsScreen::handle_event: robius_ussd::init done {:?}", t0.elapsed()); let _ = robius_fingerprinting::init(); - log!("[TIMING] PaymentsScreen::handle_event: robius_fingerprinting::init done {:?}", t0.elapsed()); - // Start loading the phonebook in the background so the contact - // picker is instant when the user first opens it. Runs on a - // worker thread and signals via SignalToUI when done. - nigig_uikit::shared::contact_picker::prefetch_contacts(); - log!("[TIMING] PaymentsScreen::handle_event: prefetch_contacts done {:?}", t0.elapsed()); self.subsystems_inited = true; } @@ -133,25 +118,22 @@ impl Widget for PaymentsScreen { // ── PaySheetRequest from child pages ───────────────────────────────── if actions.open_new_expense_requested() { - self.pay_sheet_opened = true; self.view .shared_pay_sheet(cx, ids!(pay_sheet)) .open_with_tab(cx, SharedPaySheetTab::NewExpense); } if actions.open_payment_requested() { - self.pay_sheet_opened = true; self.view .shared_pay_sheet(cx, ids!(pay_sheet)) .open_with_tab(cx, SharedPaySheetTab::Payment); } // ── SharedPaySheet actions ─────────────────────────────────────────── - if self.pay_sheet_opened { - if let Some(a) = self - .view - .shared_pay_sheet(cx, ids!(pay_sheet)) - .action(&actions) - { + if let Some(a) = self + .view + .shared_pay_sheet(cx, ids!(pay_sheet)) + .action(&actions) + { match a { SharedPaySheetAction::ExpenseSaved(_merchant) => { let sheet = self.view.shared_pay_sheet(cx, ids!(pay_sheet)); @@ -282,16 +264,12 @@ impl Widget for PaymentsScreen { _ => {} } } - } cx.extend_actions(actions); } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - let t0 = Instant::now(); - let r = self.view.draw_walk(cx, scope, walk); - log!("[TIMING] PaymentsScreen::draw_walk: {:?}", t0.elapsed()); - r + self.view.draw_walk(cx, scope, walk) } } @@ -306,9 +284,6 @@ impl PaymentsScreen { /// Drain all pending pay_flow events and forward fingerprint feedback to /// the sheet. Called both on timer tick and on every normal event pass. fn drain_flow_events(&mut self, cx: &mut Cx) { - if !self.pay_sheet_opened { - return; - } let flow_events = nigig_pay_ui::pay_flow::tick(); let n = flow_events.len(); if n == 0 { diff --git a/crates/apps/nigig-property/Cargo.toml b/crates/apps/nigig-property/Cargo.toml index e017018..4104d23 100644 --- a/crates/apps/nigig-property/Cargo.toml +++ b/crates/apps/nigig-property/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-rider/Cargo.toml b/crates/apps/nigig-rider/Cargo.toml index 9525840..b7d742d 100644 --- a/crates/apps/nigig-rider/Cargo.toml +++ b/crates/apps/nigig-rider/Cargo.toml @@ -10,7 +10,7 @@ edition = "2021" # The example app in makepad-example-map uses the same flag. # `map_style` belongs to the Nigig map crate; the Robrix Makepad fork exposes # its built-in map widget through the `maps` feature only. -makepad-widgets = { workspace = true, features = ["maps"] } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc", features = ["maps"] } nigig-map = { path = "../map" } nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } diff --git a/crates/apps/nigig-shop/Cargo.toml b/crates/apps/nigig-shop/Cargo.toml index a7d5970..e54b20c 100644 --- a/crates/apps/nigig-shop/Cargo.toml +++ b/crates/apps/nigig-shop/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-sms/Cargo.toml b/crates/apps/nigig-sms/Cargo.toml index f218934..5018e74 100644 --- a/crates/apps/nigig-sms/Cargo.toml +++ b/crates/apps/nigig-sms/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/apps/nigig-tow/Cargo.toml b/crates/apps/nigig-tow/Cargo.toml index d259c9f..c911f44 100644 --- a/crates/apps/nigig-tow/Cargo.toml +++ b/crates/apps/nigig-tow/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-vehicle/Cargo.toml b/crates/apps/nigig-vehicle/Cargo.toml index 35341c5..88d4575 100644 --- a/crates/apps/nigig-vehicle/Cargo.toml +++ b/crates/apps/nigig-vehicle/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig-walkie/Cargo.toml b/crates/apps/nigig-walkie/Cargo.toml index a12e7dd..d57fd32 100644 --- a/crates/apps/nigig-walkie/Cargo.toml +++ b/crates/apps/nigig-walkie/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/nigig_doc_scanner/Cargo.toml b/crates/apps/nigig_doc_scanner/Cargo.toml index 80d5948..3891631 100644 --- a/crates/apps/nigig_doc_scanner/Cargo.toml +++ b/crates/apps/nigig_doc_scanner/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/pdf/pdf-makepad/Cargo.toml b/crates/apps/pdf/pdf-makepad/Cargo.toml index 5c53cbf..dc7cf22 100644 --- a/crates/apps/pdf/pdf-makepad/Cargo.toml +++ b/crates/apps/pdf/pdf-makepad/Cargo.toml @@ -11,13 +11,15 @@ nigig-pdf-document = { path = "../pdf-document" } nigig-pdf-graphics = { path = "../pdf-graphics" } # The `test` feature gates makepad-widgets' re-export of makepad-test, # which tests/ui.rs imports as makepad_widgets::makepad_test. -makepad-widgets = { workspace = true, features = ["test"] } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc", features = ["test"] } + [dev-dependencies] # Both are required, and neither is redundant: `ui.rs` imports the symbols # through the re-export above, but the `#[makepad_test]` attribute expands # to an absolute `::makepad_test::` path, which only resolves if the crate # is also a direct dependency. Dropping either breaks the UI tests. -makepad-test = { workspace = true } +makepad-test = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc", package = "makepad-test" } + # A binary host so makepad_test can drive the widget through real event # delivery; the crate itself remains a library. [[bin]] diff --git a/crates/apps/spreadsheet/spreadsheet-engine/Cargo.toml b/crates/apps/spreadsheet/spreadsheet-engine/Cargo.toml index 2e473ba..d848d06 100644 --- a/crates/apps/spreadsheet/spreadsheet-engine/Cargo.toml +++ b/crates/apps/spreadsheet/spreadsheet-engine/Cargo.toml @@ -5,6 +5,5 @@ edition = "2021" description = "Pure-Rust spreadsheet engine: formula evaluation, undo/redo, dependency graph, serialization. No makepad dependency." [dependencies] -calamine = "0.36.1" # No dependencies! The engine is pure Rust. # Color ↔ Vec4f conversion is done in the UI crate, not here. diff --git a/crates/apps/spreadsheet/spreadsheet-engine/src/lib.rs b/crates/apps/spreadsheet/spreadsheet-engine/src/lib.rs index 6110a9a..8b9304c 100644 --- a/crates/apps/spreadsheet/spreadsheet-engine/src/lib.rs +++ b/crates/apps/spreadsheet/spreadsheet-engine/src/lib.rs @@ -26,16 +26,13 @@ pub mod undo; pub mod util; pub mod workbook; pub mod workbook_api; -pub mod xls_import; // Public re-exports — the names downstream code expects. pub use data::{CellData, CellId, SpreadsheetData}; -pub use persistence::FileEntry; pub use sheet::Sheet; pub use style::{BorderEdge, BorderTarget, CellAlign, CellStyle, Color, NumberFormat}; pub use undo::{Change, ChangeSet}; pub use workbook_api::{Workbook, WorkbookCommand}; -pub use xls_import::import_workbook_from_path; #[cfg(test)] pub(crate) mod test_disk { diff --git a/crates/apps/spreadsheet/spreadsheet-engine/src/persistence.rs b/crates/apps/spreadsheet/spreadsheet-engine/src/persistence.rs index 8ca7410..7b9946a 100644 --- a/crates/apps/spreadsheet/spreadsheet-engine/src/persistence.rs +++ b/crates/apps/spreadsheet/spreadsheet-engine/src/persistence.rs @@ -22,26 +22,11 @@ const GENERATED_SHEET_FILE: &str = "current.workbook.tsv"; /// Backward-compatible legacy filename (single-sheet format). const LEGACY_GENERATED_SHEET_FILE: &str = "current.sheet.csv"; -/// Metadata about a saved workbook file for the dashboard. -#[derive(Clone, Debug)] -pub struct FileEntry { - pub filename: String, - pub path: PathBuf, - /// First sheet name, or "Workbook" if unavailable. - pub title: String, - /// First non-empty cell value (A1), for the preview snippet. - pub preview: String, - /// File size in bytes. - pub size: u64, - /// Last modified time (seconds since UNIX_EPOCH), or 0 if unknown. - pub modified: u64, -} - fn sheet_manifest_path() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() } -pub fn sheet_generated_dir_path() -> PathBuf { +fn sheet_generated_dir_path() -> PathBuf { sheet_manifest_path().join(GENERATED_DIR) } @@ -53,151 +38,6 @@ fn sheet_generated_legacy_file_path() -> PathBuf { sheet_generated_dir_path().join(LEGACY_GENERATED_SHEET_FILE) } -/// List all workbook files in the generated directory, sorted by -/// last-modified descending (most recent first). Each entry includes -/// a preview derived from the file's contents. -/// -/// Files whose names do not match `validate_filename` are skipped. -/// The default saved file (`current.workbook.tsv`) is included. -pub fn list_spreadsheet_files() -> Vec { - let dir = sheet_generated_dir_path(); - let Ok(entries) = fs::read_dir(&dir) else { - return Vec::new(); - }; - - let mut files: Vec<_> = entries - .filter_map(|entry| { - let entry = entry.ok()?; - let name = entry.file_name().to_string_lossy().into_owned(); - validate_filename(&name).ok()?; - let path = entry.path(); - let metadata = entry.metadata().ok()?; - Some((name, path, metadata)) - }) - .collect(); - - // Pre-parse previews while we have the file handles. - let mut result = Vec::with_capacity(files.len()); - for (name, path, metadata) in files.drain(..) { - let preview = preview_workbook_file(&path); - result.push(FileEntry { - filename: name, - path, - title: preview.title, - preview: preview.snippet, - size: metadata.len(), - modified: metadata.modified().ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0), - }); - } - - // Sort by modified time descending (most recent first). - result.sort_by(|a, b| b.modified.cmp(&a.modified)); - result -} - -/// Lightweight preview of a workbook file: sheet name + first cell. -struct FilePreview { - title: String, - snippet: String, -} - -fn preview_workbook_file(path: &Path) -> FilePreview { - let Ok(contents) = fs::read_to_string(path) else { - return FilePreview { - title: path.file_stem().and_then(|s| s.to_str()).unwrap_or("Workbook").to_string(), - snippet: String::new(), - }; - }; - - match crate::workbook::detect_workbook_format(&contents) { - Some(crate::workbook::WorkbookFormat::V2) => { - // Extract first sheet name and first cell from the V2 format. - let title = extract_first_sheet_name(&contents); - let snippet = extract_first_cell_v2(&contents); - FilePreview { title, snippet } - } - _ => { - // Legacy format: just use the filename as title. - let title = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Workbook").to_string(); - let snippet = extract_first_cell_legacy(&contents); - FilePreview { title, snippet } - } - } -} - -/// Extract the first sheet name from a V2 workbook serialization. -fn extract_first_sheet_name(data: &str) -> String { - for line in data.lines() { - if let Some(rest) = line.strip_prefix("SHEET\t") { - return crate::workbook::unesc(rest); - } - } - // No sheet name found — derive from magic header. - "Workbook".to_string() -} - -/// Extract the first cell's value from a V2 workbook. -fn extract_first_cell_v2(data: &str) -> String { - let mut in_sheet = false; - for line in data.lines() { - if line.starts_with("SHEET\t") { - in_sheet = true; - continue; - } - if line == "END_SHEET" { - in_sheet = false; - continue; - } - if !in_sheet { - continue; - } - if let Some(rest) = line.strip_prefix("CELL\t") { - // Format: CELL\t\t\t\t\t - // We need at least row, col, value. - let parts: Vec<&str> = rest.splitn(3, '\t').collect(); - if parts.len() >= 3 { - let value = crate::workbook::unesc(parts[2]); - // The value field may contain trailing tabs if the cell - // format is minimal (only 3 parts); splitn(3) lumps - // everything after col into parts[2]. - let first_tab = value.find('\t'); - let value = match first_tab { - Some(idx) => &value[..idx], - None => &value, - }; - if !value.is_empty() { - return value.to_string(); - } - } - } - } - String::new() -} - -/// Extract first cell value from legacy single-sheet format. -fn extract_first_cell_legacy(data: &str) -> String { - for line in data.lines() { - if let Some(rest) = line.strip_prefix("CELL\t") { - let parts: Vec<&str> = rest.splitn(3, '\t').collect(); - if parts.len() >= 3 { - let value = crate::workbook::unesc(parts[2]); - let first_tab = value.find('\t'); - let value = match first_tab { - Some(idx) => &value[..idx], - None => &value, - }; - if !value.is_empty() { - return value.to_string(); - } - } - } - } - String::new() -} - /// Load the most recently saved workbook state, preferring the new /// multi-sheet format. Returns `None` if neither file exists or both /// are empty. @@ -404,85 +244,4 @@ mod tests { fs::write(&legacy, text).ok(); } } - - /// list_spreadsheet_files returns files with correct preview data. - #[test] - fn list_spreadsheet_files_extracts_previews() { - let _disk = crate::test_disk::lock(); - - // Save a test workbook with a known first cell. - let name = "coverage-list-files-test.tsv"; - let wb_data = crate::workbook::serialize_workbook( - &[crate::Sheet::new("My Test Sheet", { - let mut d = crate::SpreadsheetData::default(); - d.set_cell(0, 0, "Preview Value"); - d - })], - 0, - ); - save_spreadsheet_state_as(name, &wb_data).expect("save failed"); - - let dir = sheet_generated_dir_path(); - let path = dir.join(name); - - let files = list_spreadsheet_files(); - let entry = files - .iter() - .find(|f| f.filename == name) - .unwrap_or_else(|| panic!("expected to find {name} in file listing")); - - assert_eq!(entry.title, "My Test Sheet"); - assert_eq!(entry.preview, "Preview Value"); - assert!(entry.size > 0); - - // Cleanup - fs::remove_file(&path).ok(); - } - - /// validate_filename is used internally by list_spreadsheet_files - /// to skip invalid entries. - #[test] - fn list_spreadsheet_files_skips_invalid_names() { - let files = list_spreadsheet_files(); - for entry in &files { - // Every entry must pass filename validation — no hidden files, - // no paths with separators, etc. - assert!(validate_filename(&entry.filename).is_ok()); - } - } - - /// extract_first_sheet_name pulls the sheet name from SHEET lines. - #[test] - fn extract_first_sheet_name_from_v2() { - let data = "#MP_WORKBOOK_V2\nACTIVE\t0\nSHEET\tFirst Sheet\nCELL\t0\t0\tHello\nEND_SHEET\n"; - assert_eq!(extract_first_sheet_name(data), "First Sheet"); - } - - /// extract_first_sheet_name falls back to "Workbook" when no sheet is found. - #[test] - fn extract_first_sheet_name_default() { - let data = "#MP_WORKBOOK_V2\nACTIVE\t0\n"; - assert_eq!(extract_first_sheet_name(data), "Workbook"); - } - - /// extract_first_cell_v2 finds the first non-empty cell value. - #[test] - fn extract_first_cell_v2_finds_value() { - let data = "#MP_WORKBOOK_V2\nACTIVE\t0\nSHEET\tSheet1\nCELL\t0\t0\tFirstValue\nCELL\t0\t1\tSecondValue\nEND_SHEET\n"; - assert_eq!(extract_first_cell_v2(data), "FirstValue"); - } - - /// extract_first_cell_v2 skips empty cells. - #[test] - fn extract_first_cell_v2_skips_empty() { - let data = "#MP_WORKBOOK_V2\nACTIVE\t0\nSHEET\tSheet1\nCELL\t0\t0\t\nCELL\t0\t1\tFound!\nEND_SHEET\n"; - assert_eq!(extract_first_cell_v2(data), "Found!"); - } - - /// extract_first_cell_v2 returns empty when no cells have values. - #[test] - fn extract_first_cell_v2_empty_workbook() { - let data = "#MP_WORKBOOK_V2\nACTIVE\t0\nSHEET\tSheet1\nEND_SHEET\n"; - assert_eq!(extract_first_cell_v2(data), ""); - } } diff --git a/crates/apps/spreadsheet/spreadsheet-engine/src/xls_import.rs b/crates/apps/spreadsheet/spreadsheet-engine/src/xls_import.rs deleted file mode 100644 index 2bfaea4..0000000 --- a/crates/apps/spreadsheet/spreadsheet-engine/src/xls_import.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Import spreadsheets from Excel files (`.xls`, `.xlsx`, `.xlsb`, `.ods`) -//! using the `calamine` crate. -//! -//! The `import_workbook_from_path` function reads an Excel workbook and -//! converts each worksheet into a `Sheet` containing a `SpreadsheetData` -//! with populated cells. Formula cells are preserved as-is (the -//! spreadsheet engine will parse and evaluate them at load time). - -use crate::{Sheet, SpreadsheetData}; -use calamine::{Data, Reader, open_workbook_auto}; - -/// Convert a `calamine::Data` cell into a string representation suitable -/// for storage in `SpreadsheetData`. -fn convert_cell_value(data: &Data) -> String { - match data { - Data::Empty => String::new(), - Data::String(s) => s.clone(), - Data::Int(i) => i.to_string(), - Data::Float(f) => { - if f.fract() == 0.0 && f.abs() < 1e15 { - format!("{}", *f as i64) - } else { - format!("{}", f) - } - } - Data::Bool(b) => b.to_string(), - Data::DateTime(dt) => { - // Excel datetime — render as a rough string. We could - // format this better, but for now a simple serial representation. - dt.to_string() - } - Data::DateTimeIso(s) => s.clone(), - Data::DurationIso(s) => s.clone(), - Data::Error(e) => format!("#{}", e), - } -} - -/// Import an Excel workbook from a file path. -/// -/// Returns a list of `Sheet`s (the workbook) and the index of the -/// active/selected sheet (0). -/// -/// Supports `.xls` (BIFF), `.xlsx`/`.xlsm` (OOXML), `.xlsb` (BIFF12), -/// and `.ods` (OpenDocument). -pub fn import_workbook_from_path( - path: impl AsRef, -) -> Result<(Vec, usize), String> { - let path = path.as_ref(); - let file_stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("Imported") - .to_string(); - - let mut workbook = - open_workbook_auto(path).map_err(|e| format!("Failed to open workbook: {}", e))?; - - let sheet_names = workbook.sheet_names().to_vec(); - if sheet_names.is_empty() { - return Err("Workbook contains no sheets".to_string()); - } - - let mut sheets = Vec::new(); - let mut active_sheet = 0usize; - - for (i, sheet_name) in sheet_names.iter().enumerate() { - let range = workbook - .worksheet_range(sheet_name) - .map_err(|e| format!("Failed to read sheet '{}': {}", sheet_name, e))?; - - let (rows, cols) = range.get_size(); - let mut data = SpreadsheetData::default(); - - for (row_idx, row) in range.rows().enumerate() { - for (col_idx, cell) in row.iter().enumerate() { - let row_u32 = row_idx as u32; - let col_u32 = col_idx as u32; - let value = convert_cell_value(cell); - - // Only set non-empty strings to avoid creating empty cell entries. - if !value.is_empty() { - data.set_cell(row_u32, col_u32, &value); - } - } - } - - // Name the sheet as it was in the Excel file, or use a fallback. - let name = if sheet_name.is_empty() { - format!("{} {}", file_stem, i + 1) - } else { - sheet_name.clone() - }; - - sheets.push(Sheet::new(name, data)); - } - - // Default to the first sheet as active. - active_sheet = 0; - - Ok((sheets, active_sheet)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_convert_int() { - let data = Data::Int(42); - assert_eq!(convert_cell_value(&data), "42"); - } - - #[test] - fn test_convert_float_whole() { - let data = Data::Float(3.0); - assert_eq!(convert_cell_value(&data), "3"); - } - - #[test] - fn test_convert_float_decimal() { - let data = Data::Float(3.14); - assert_eq!(convert_cell_value(&data), "3.14"); - } - - #[test] - fn test_convert_string() { - let data = Data::String("hello".to_string()); - assert_eq!(convert_cell_value(&data), "hello"); - } - - #[test] - fn test_convert_bool() { - let data = Data::Bool(true); - assert_eq!(convert_cell_value(&data), "true"); - } - - #[test] - fn test_convert_empty() { - let data = Data::Empty; - assert_eq!(convert_cell_value(&data), ""); - } - - #[test] - fn test_import_old_mutual_budget_tool() { - // This is a real BIFF8 (.xls) file. We can only run this test - // when the file exists on disk; otherwise skip. - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(4) - .unwrap() - .join("ui/Old_Mutual_Finance_Budget_Tool.xls"); - - if !path.exists() { - eprintln!("Skipping integration test: {} not found", path.display()); - return; - } - - let result = import_workbook_from_path(&path); - assert!(result.is_ok(), "Expected import to succeed: {:?}", result.err()); - let (sheets, active) = result.unwrap(); - assert!(!sheets.is_empty(), "Expected at least one sheet"); - assert_eq!(active, 0, "Active sheet should be index 0"); - - // Print the first sheet's first few rows for sanity. - for (i, sheet) in sheets.iter().take(2).enumerate() { - eprintln!("Sheet {} ({}):", i, sheet.name); - for row in 0..5.min(crate::data::NUM_ROWS) { - for col in 0..5.min(crate::data::NUM_COLS) { - let val = sheet.data.get_display_value(row, col); - if !val.is_empty() { - eprint!("[{},{}]={:?} ", row, col, val); - } - } - } - eprintln!(); - } - } -} diff --git a/crates/apps/spreadsheet/spreadsheet-engine/tests/xls_import.rs b/crates/apps/spreadsheet/spreadsheet-engine/tests/xls_import.rs deleted file mode 100644 index a7fd346..0000000 --- a/crates/apps/spreadsheet/spreadsheet-engine/tests/xls_import.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Integration test: import a real Excel (.xls) file into the spreadsheet engine. - -use spreadsheet_engine::import_workbook_from_path; - -/// This test reads the real "Old_Mutual_Finance_Budget_Tool.xls" file -/// from the repository's `ui/` directory and verifies that the import -/// produces sheets with cell data. -#[test] -fn import_old_mutual_budget_tool_xls() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(4) - .unwrap() - .join("ui/Old_Mutual_Finance_Budget_Tool.xls"); - - if !path.exists() { - eprintln!("Skipping: {} not found", path.display()); - return; - } - - let result = import_workbook_from_path(&path); - assert!(result.is_ok(), "Import should succeed: {:?}", result.err()); - - let (sheets, active) = result.unwrap(); - assert!(!sheets.is_empty(), "Should have at least one sheet"); - assert_eq!(active, 0, "Active sheet should be index 0"); - - // The first sheet should be named "monthly budget planner". - assert_eq!(sheets[0].name, "monthly budget planner"); - - // Verify there is at least some cell content. - let mut has_cells = false; - for sheet in &sheets { - for (_id, cell) in &sheet.data.cells { - if !cell.value.is_empty() { - has_cells = true; - break; - } - } - if has_cells { - break; - } - } - assert!(has_cells, "Imported workbook should have cell data"); -} diff --git a/crates/apps/spreadsheet/spreadsheet-ui/Cargo.toml b/crates/apps/spreadsheet/spreadsheet-ui/Cargo.toml index a569962..d13c1d4 100644 --- a/crates/apps/spreadsheet/spreadsheet-ui/Cargo.toml +++ b/crates/apps/spreadsheet/spreadsheet-ui/Cargo.toml @@ -21,9 +21,8 @@ description = "Makepad widget wrappers for the spreadsheet engine." # file does not compile at all — it was checked in against a manifest that # never enabled the feature, so `cargo test --all-targets` failed outright # and `cargo test --lib` skipped it silently. Matches `pdf-makepad`. -makepad-widgets = { workspace = true, features = ["test"] } -robius-file-picker = { git = "https://github.com/project-robius/robius", rev = "b766e62b0600f5d2ee21cc6995648346fc277bd8" } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc", features = ["test"] } spreadsheet-engine = { path = "../spreadsheet-engine" } [dev-dependencies] -makepad-test = { workspace = true } +makepad-test = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc", package = "makepad-test" } diff --git a/crates/apps/spreadsheet/spreadsheet-ui/src/dashboard.rs b/crates/apps/spreadsheet/spreadsheet-ui/src/dashboard.rs deleted file mode 100644 index fa308c1..0000000 --- a/crates/apps/spreadsheet/spreadsheet-ui/src/dashboard.rs +++ /dev/null @@ -1,359 +0,0 @@ -//! `SpreadsheetDashboard` widget: the file-list view shown on first -//! launch or before a workbook is opened. -//! -//! Shows saved spreadsheet files from the `generated/` directory in a -//! grid of preview cards. A "+" button in the header creates a new -//! blank workbook. Clicking a card opens that workbook. - -use makepad_widgets::makepad_platform::event::TouchState; -use makepad_widgets::*; - -use crate::model::SharedWorkspaceModel; -use spreadsheet_engine::persistence::{list_spreadsheet_files, FileEntry}; - -/// Emitted to the workspace when the dashboard wants to switch views. -#[derive(Clone, Debug)] -pub enum DashboardAction { - /// Create a new blank workbook (replaces current model). - NewWorkbook, - /// Open an existing file by filename (from `generated/`). - OpenFile(String), - /// User wants to go back to the dashboard. - BackToDashboard, - /// User wants to import an Excel file (.xls/.xlsx) from a - /// platform-native file dialog. - ImportExcel, -} - -#[derive(Script, ScriptHook, Widget)] -pub struct SpreadsheetDashboard { - #[deref] - view: View, - #[rust] - model: SharedWorkspaceModel, - #[rust] - files: Vec, - #[rust] - pub action: Option, - #[rust] - initialized: bool, - - // --- Draw resources for the file card grid (manual rendering) --- - #[live] - draw_card_bg: DrawColor, - #[live] - draw_card_hover_bg: DrawColor, - #[live] - draw_card_text: DrawText, - #[live] - draw_card_preview: DrawText, - #[live] - card_normal_color: Vec4f, - #[live] - card_hover_color: Vec4f, - #[live] - card_text_color: Vec4f, - #[live] - card_preview_color: Vec4f, - - /// Hit-test areas for each file card. - #[rust] - card_areas: Vec<(usize, Rect)>, - /// The dashboard's rect, stored during draw_walk for hit-testing. - #[rust] - rect: Rect, - - /// Index of the card currently under the cursor (for hover highlight). - #[rust] - hover_card: Option, -} - -/// Toggle the dashboard's own visibility for the workspace overlay. -/// The runtime widget is this component, so the workspace cannot -/// downcast it to a plain `View`; this forwards to the component's -/// root view instead. -impl SpreadsheetDashboard { - pub fn set_dash_visible(&mut self, cx: &mut Cx, visible: bool) { - self.view.set_visible(cx, visible); - } -} - -impl Widget for SpreadsheetDashboard { - fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - self.view.handle_event(cx, event, scope); - - if let Event::Actions(actions) = event { - self.handle_actions(cx, actions, scope); - } - - self.handle_card_clicks(cx, event); - } - - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - if !self.initialized { - self.refresh_files(); - self.initialized = true; - } - - let draw_step = self.view.draw_walk(cx, scope, walk); - - // Store rect for hit-testing. - self.rect = self.view.area().rect(cx); - - // Draw the file cards manually. - if !self.files.is_empty() { - self.draw_cards(cx); - } - - draw_step - } -} - -impl SpreadsheetDashboard { - /// Refresh the file listing from disk. - pub fn refresh_files(&mut self) { - self.files = list_spreadsheet_files(); - self.card_areas.clear(); - } - - /// Called by the workspace when it becomes visible. - /// The `cx` is needed for redraw. - pub fn refresh_and_redraw(&mut self, cx: &mut Cx) { - self.files = list_spreadsheet_files(); - self.card_areas.clear(); - self.view.redraw(cx); - } - - /// Draw file card previews onto the canvas. - fn draw_cards(&mut self, cx: &mut Cx2d) { - self.card_areas.clear(); - - let area = self.view.area().rect(cx); - let card_w = 240.0_f64; - let card_h = 100.0_f64; - let margin_x = 16.0_f64; - let margin_y = 80.0_f64; - let spacing_x = 20.0_f64; - let spacing_y = 16.0_f64; - - let cols = ((area.size.x - margin_x * 2.0 + spacing_x) / (card_w + spacing_x)) as usize; - let cols = cols.max(1); - - let mut col = 0usize; - let mut row = 0usize; - - for (i, entry) in self.files.iter().enumerate() { - let x = area.pos.x + margin_x + col as f64 * (card_w + spacing_x); - let y = area.pos.y + margin_y + row as f64 * (card_h + spacing_y); - - let card_rect = Rect { - pos: DVec2 { x, y }, - size: DVec2 { - x: card_w, - y: card_h, - }, - }; - - // Background - let is_hovered = self.hover_card == Some(i); - self.draw_card_bg.color = if is_hovered { - self.card_hover_color - } else { - self.card_normal_color - }; - self.draw_card_bg.draw_abs(cx, card_rect); - - // Title - self.draw_card_text.color = self.card_text_color; - self.draw_card_text.draw_abs( - cx, - DVec2 { - x: card_rect.pos.x + 12.0, - y: card_rect.pos.y + 12.0, - }, - &entry.title, - ); - - // Preview snippet (first cell value) - let preview = if entry.preview.is_empty() { - "(empty)" - } else { - &entry.preview - }; - self.draw_card_preview.color = self.card_preview_color; - self.draw_card_preview.draw_abs( - cx, - DVec2 { - x: card_rect.pos.x + 12.0, - y: card_rect.pos.y + 34.0, - }, - preview, - ); - - // Filename - self.draw_card_preview.draw_abs( - cx, - DVec2 { - x: card_rect.pos.x + 12.0, - y: card_rect.pos.y + 52.0, - }, - &entry.filename, - ); - - // File size - let size_str = if entry.size < 1024 { - format!("{} B", entry.size) - } else { - format!("{:.1} KB", entry.size as f64 / 1024.0) - }; - self.draw_card_preview.draw_abs( - cx, - DVec2 { - x: card_rect.pos.x + 12.0, - y: card_rect.pos.y + 68.0, - }, - &size_str, - ); - - self.card_areas.push((i, card_rect)); - - col += 1; - if col >= cols { - col = 0; - row += 1; - } - } - } - - /// Handle clicks on file cards. - fn handle_card_clicks(&mut self, cx: &mut Cx, event: &Event) { - // Track hover for visual feedback via mouse move events. - if let Hit::FingerMove(fme) = event.hits_with_capture_overload(cx, self.draw_bg.area(), true) { - let pos = fme.abs; - let new_hover = self - .card_areas - .iter() - .find(|(_, rect)| rect.contains(pos)) - .map(|(i, _)| *i); - if new_hover != self.hover_card { - self.hover_card = new_hover; - self.view.redraw(cx); - } - } - - // Handle touch events (mobile/touch devices). - if let Event::TouchUpdate(tu) = event { - for touch in &tu.touches { - if touch.state == TouchState::Stop { - for &(idx, rect) in &self.card_areas { - if rect.contains(touch.abs) { - let filename = self.files[idx].filename.clone(); - self.action = Some(DashboardAction::OpenFile(filename)); - return; - } - } - } - } - } - - // Handle mouse click events. - if let Hit::FingerUp(fe) = event.hits_with_capture_overload(cx, self.draw_bg.area(), true) { - if fe.is_primary_hit() { - for &(idx, rect) in &self.card_areas { - if rect.contains(fe.abs) { - let filename = self.files[idx].filename.clone(); - self.action = Some(DashboardAction::OpenFile(filename)); - return; - } - } - } - } - } - - /// Signal to the workspace that a new workbook should be created. - fn create_new_workbook(&mut self, cx: &mut Cx) { - self.action = Some(DashboardAction::NewWorkbook); - self.view.redraw(cx); - } -} - -impl WidgetMatchEvent for SpreadsheetDashboard { - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { - if self.button(cx, ids!(new_workbook_btn)).clicked(actions) { - self.create_new_workbook(cx); - } - - if self.button(cx, ids!(refresh_btn)).clicked(actions) { - self.refresh_and_redraw(cx); - } - - if self.button(cx, ids!(back_btn)).clicked(actions) { - self.action = Some(DashboardAction::BackToDashboard); - self.view.redraw(cx); - } - - if self.button(cx, ids!(import_excel_btn)).clicked(actions) { - self.action = Some(DashboardAction::ImportExcel); - self.view.redraw(cx); - } - } -} - -script_mod! { - use mod.prelude.widgets.* - - mod.widgets.SpreadsheetDashboard = #(SpreadsheetDashboard::register_widget(vm)) { - width: Fill, height: Fill, flow: Down - draw_bg +: { color: #x1a1a2e } - - dashboard_header := View { - width: Fill, height: 56.0, flow: Right - padding: Inset{left: 20.0, right: 20.0, top: 0, bottom: 0}, spacing: 12.0, align: Align{y: 0.5} - draw_bg +: { color: #x242438 } - - dashboard_title := Label { - text: "Spreadsheets", - draw_text +: { color: #xd8d8e8, text_style: theme.font_bold { font_size: 18.0 } } - } - spacer := View { width: Fill } - refresh_btn := Button { - text: "Refresh", - width: 80.0, height: 32.0 - draw_bg +: { color: #x313244 } - draw_text +: { color: #xd8d8e8, text_style +: { font_size: 11.0 } } - } - new_workbook_btn := Button { - text: "+ New", - width: 80.0, height: 32.0 - draw_bg +: { color: #x238636 } - draw_text +: { color: #xffffff, text_style +: { font_size: 11.0 } } - } - import_excel_btn := Button { - text: "Import Excel", - width: 100.0, height: 32.0 - draw_bg +: { color: #x2a5a8a } - draw_text +: { color: #xffffff, text_style +: { font_size: 11.0 } } - } - } - - cards_container := View { - width: Fill, height: Fill - draw_bg +: { color: #x1a1a2e } - } - - back_btn := Button { - visible: false - width: 0, height: 0 - } - - // Manual draw resources for file cards - draw_card_bg +: { draw_depth: 0.1 } - draw_card_hover_bg +: { draw_depth: 0.2 } - draw_card_text +: { draw_depth: 0.3 color: #xd8d8e8 text_style: theme.font_bold { font_size: 14.0 } } - draw_card_preview +: { draw_depth: 0.3 color: #x8a8aa5 text_style: theme.font_regular { font_size: 11.0 } } - card_normal_color: #x2a2a40 - card_hover_color: #x3a3a5a - card_text_color: #xd8d8e8 - card_preview_color: #x8a8aa5 - } -} \ No newline at end of file diff --git a/crates/apps/spreadsheet/spreadsheet-ui/src/lib.rs b/crates/apps/spreadsheet/spreadsheet-ui/src/lib.rs index 229f838..3354291 100644 --- a/crates/apps/spreadsheet/spreadsheet-ui/src/lib.rs +++ b/crates/apps/spreadsheet/spreadsheet-ui/src/lib.rs @@ -3,7 +3,6 @@ //! Provides: //! - `SpreadsheetGrid` — the scrollable, editable cell grid widget //! - `SpreadsheetWorkspace` — toolbar, formula bar, tab strip -//! - `SpreadsheetDashboard` — file-list view with preview cards //! - `CellAction` — action enum emitted by the grid //! - `key_to_char` — hardware key to character mapping (needs makepad) //! @@ -14,7 +13,6 @@ pub mod button; pub mod chart; pub mod checkbox; pub mod clipboard; -pub mod dashboard; pub mod dropdown; pub mod edit; pub mod event_router; @@ -42,7 +40,6 @@ pub use spreadsheet_engine::*; // Re-export UI types. pub use grid::{key_to_char, CellAction, GridIntent, SpreadsheetGrid}; pub use workspace::SpreadsheetWorkspace; -pub use dashboard::{DashboardAction, SpreadsheetDashboard}; use makepad_widgets::ScriptVm; @@ -52,6 +49,5 @@ pub fn script_mod(vm: &mut ScriptVm) { grid::script_mod(vm); trend_chart::script_mod(vm); virtual_grid::script_mod(vm); - dashboard::script_mod(vm); workspace::script_mod(vm); } diff --git a/crates/apps/spreadsheet/spreadsheet-ui/src/workspace.rs b/crates/apps/spreadsheet/spreadsheet-ui/src/workspace.rs index fa6d6e8..56825b0 100644 --- a/crates/apps/spreadsheet/spreadsheet-ui/src/workspace.rs +++ b/crates/apps/spreadsheet/spreadsheet-ui/src/workspace.rs @@ -26,31 +26,6 @@ use spreadsheet_engine::sheet::Sheet; use spreadsheet_engine::style::{BorderTarget, CellAlign, NumberFormat}; use spreadsheet_engine::util::cell_ref_string; use spreadsheet_engine::workbook::deserialize_workbook; -use spreadsheet_engine::Workbook; - -/// Outcome of the async Excel import file picker. -/// -/// The `robius-file-picker` completion callback runs off the UI thread with -/// no `Cx`, so it parks the outcome here and raises a UI signal; -/// `SpreadsheetWorkspace::drain_excel_import` applies it on the next -/// `Event::Signal`. This is the same shape the invoicer/SMS apps use. -enum ExcelImportOutcome { - Picked(std::path::PathBuf), - Failed(String), -} - -static PENDING_EXCEL_IMPORT: std::sync::Mutex> = - std::sync::Mutex::new(None); - -/// Lock, recovering the guard if the mutex was poisoned, then park an -/// outcome and poke the UI thread. -fn park_import(outcome: ExcelImportOutcome) { - let mut guard = PENDING_EXCEL_IMPORT - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *guard = Some(outcome); - SignalToUI::set_ui_signal(); -} #[derive(Script, ScriptHook, Widget)] pub struct SpreadsheetWorkspace { @@ -114,12 +89,6 @@ pub struct SpreadsheetWorkspace { /// spreadsheet grid. #[rust] big_mode: bool, - - // --- Dashboard navigation (C2) --- - /// Whether we're on the dashboard (file list) or the editor. - /// Starts as dashboard; transitions to editor on New/Open. - #[rust(true)] - show_dashboard: bool, } impl Widget for SpreadsheetWorkspace { @@ -127,12 +96,6 @@ impl Widget for SpreadsheetWorkspace { self.view.handle_event(cx, event, scope); self.apply_grid_intents(cx); - // The robius file picker's completion callback runs off the UI - // thread and parks its result; `drain_excel_import` lands it here. - if matches!(event, Event::Signal) { - self.drain_excel_import(cx); - } - // Live chart ticker: advance the market and re-feed the charts. if let Some(timer) = &self.timer { if timer.is_event(event).is_some() { @@ -152,9 +115,6 @@ impl Widget for SpreadsheetWorkspace { // Toolbar actions can invoke Grid mutations directly; dispatch // any intents queued during handle_actions in the same event. self.apply_grid_intents(cx); - - // Check for dashboard actions (new workbook, open file). - self.handle_dashboard_actions(cx); } } @@ -164,90 +124,47 @@ impl Widget for SpreadsheetWorkspace { self.timer = Some(cx.start_interval(0.25)); } - // Toggle dashboard/editor visibility when the flag changes. - if self.show_dashboard { - if let Some(mut dashboard) = self - .view - .widget(cx, ids!(dashboard)) - .borrow_mut::() - { - dashboard.set_dash_visible(cx, true); - // Refresh the dashboard file list when it becomes visible. - dashboard.refresh_and_redraw(cx); - } - } else { - if let Some(mut dashboard) = self - .view - .widget(cx, ids!(dashboard)) - .borrow_mut::() - { - dashboard.set_dash_visible(cx, false); - } - } - // Also toggle the toolbar visibility — hide it when on dashboard. - if let Some(mut v) = self.view.widget(cx, ids!(toolbar)).borrow_mut::() { - v.set_visible(cx, !self.show_dashboard); - } - if let Some(mut v) = self.view.widget(cx, ids!(formula_bar)).borrow_mut::() { - v.set_visible(cx, !self.show_dashboard); - } - if let Some(mut v) = self.view.widget(cx, ids!(grid_container)).borrow_mut::() { - v.set_visible(cx, !self.show_dashboard); - } - if let Some(mut v) = self.view.widget(cx, ids!(chart_pane)).borrow_mut::() { - v.set_visible(cx, !self.show_dashboard); - } - if let Some(mut v) = self.view.widget(cx, ids!(sheet_tabs)).borrow_mut::() { - v.set_visible(cx, !self.show_dashboard); - } - let need_init = !self.initialized; if need_init { - if !self.show_dashboard { - // Transitioning from dashboard to editor — load the selected - // workbook into the grid. - if let Some(saved) = load_saved_spreadsheet_state() { - if let Some((sheets, active)) = deserialize_workbook(&saved) { - *self.model.borrow_mut() = WorkspaceModel::from_parts(sheets, active); - } else { - // fallback: old single-sheet save - let mut sheet = SpreadsheetData::default(); - sheet.deserialize(&saved); - *self.model.borrow_mut() = - WorkspaceModel::from_parts(vec![Sheet::new("Sheet1", sheet)], 0); - } + if let Some(saved) = load_saved_spreadsheet_state() { + if let Some((sheets, active)) = deserialize_workbook(&saved) { + *self.model.borrow_mut() = WorkspaceModel::from_parts(sheets, active); } else { - // Default: demo workbooks like the original. - let model = WorkspaceModel::from_parts( - vec![ - Sheet::new("Budget", SpreadsheetData::demo()), - Sheet::new("Q3 Projections", SpreadsheetData::demo_q3()), - Sheet::new("ROI Analysis", SpreadsheetData::demo_roi()), - ], - 0, - ); - *self.model.borrow_mut() = model; + // fallback: old single-sheet save + let mut sheet = SpreadsheetData::default(); + sheet.deserialize(&saved); + *self.model.borrow_mut() = + WorkspaceModel::from_parts(vec![Sheet::new("Sheet1", sheet)], 0); } - - self.formula_bar = FormulaBar::new(); - self.formula_was_focused = false; - self.status_cache.clear(); - self.sort_status.clear(); + } else { + // Default: demo workbooks like the original. + let model = WorkspaceModel::from_parts( + vec![ + Sheet::new("Budget", SpreadsheetData::demo()), + Sheet::new("Q3 Projections", SpreadsheetData::demo_q3()), + Sheet::new("ROI Analysis", SpreadsheetData::demo_roi()), + ], + 0, + ); + *self.model.borrow_mut() = model; } + self.formula_bar = FormulaBar::new(); + self.formula_was_focused = false; + self.status_cache.clear(); + self.sort_status.clear(); + self.initialized = true; } // Bind the shared workbook before the grid's first render. - if !self.show_dashboard { - if let Some(mut grid) = self - .view - .widget(cx, ids!(grid)) - .borrow_mut::() - { - grid.attach_workspace_model(std::rc::Rc::clone(&self.model)); - } + if let Some(mut grid) = self + .view + .widget(cx, ids!(grid)) + .borrow_mut::() + { + grid.attach_workspace_model(std::rc::Rc::clone(&self.model)); } // Let the View consume the walk and lay out all child widgets. @@ -260,35 +177,33 @@ impl Widget for SpreadsheetWorkspace { // to get a zero/wrong rect.) self.rect = self.view.area().rect(cx); - if !self.show_dashboard { - // Draw dynamic tabs on top of the tab_bar View. - self.draw_tabs(cx); + // Draw dynamic tabs on top of the tab_bar View. + self.draw_tabs(cx); - // Status bar: how many cells are currently visible (scroll/resize - // change it). Cached so the label only re-lays-out on a change. - if let Some(grid) = self.view.widget(cx, ids!(grid)).borrow::() { - let (vc, vr) = grid.visible_cell_counts(); - let status = if self.sort_status.is_empty() { - format!("Ready | {} × {} visible = {} cells", vc, vr, vc * vr) - } else { - format!( - "Ready | {} × {} visible = {} cells | {}", - vc, - vr, - vc * vr, - self.sort_status - ) - }; - if status != self.status_cache { - self.status_cache = status.clone(); - self.label(cx, ids!(status_label)).set_text(cx, &status); - } + // Status bar: how many cells are currently visible (scroll/resize + // change it). Cached so the label only re-lays-out on a change. + if let Some(grid) = self.view.widget(cx, ids!(grid)).borrow::() { + let (vc, vr) = grid.visible_cell_counts(); + let status = if self.sort_status.is_empty() { + format!("Ready | {} × {} visible = {} cells", vc, vr, vc * vr) + } else { + format!( + "Ready | {} × {} visible = {} cells | {}", + vc, + vr, + vc * vr, + self.sort_status + ) + }; + if status != self.status_cache { + self.status_cache = status.clone(); + self.label(cx, ids!(status_label)).set_text(cx, &status); } } // Defer set_active_sheet until AFTER view.draw_walk so the // grid widget definitely exists. - if need_init && !self.show_dashboard { + if need_init { let active = self.model.borrow().active_sheet(); self.set_active_sheet(cx, active, false); } @@ -298,134 +213,6 @@ impl Widget for SpreadsheetWorkspace { } impl SpreadsheetWorkspace { - /// Check the dashboard widget for pending actions (new workbook, - /// open file, back to dashboard) and dispatch them. - fn handle_dashboard_actions(&mut self, cx: &mut Cx) { - // Take the pending action out of the dashboard so we can freely - // borrow `self` afterwards without conflicting with the - // WidgetBorrowMut guard. - let pending_action: Option = { - let widget_ref = self.view.widget(cx, ids!(dashboard)); - let Some(mut dashboard) = - widget_ref.borrow_mut::() - else { - return; - }; - dashboard.action.take() - }; - - let Some(action) = pending_action else { return }; - - match action { - crate::dashboard::DashboardAction::NewWorkbook => { - let model = WorkspaceModel::new(Workbook::with_sheets( - vec![Sheet::new("Sheet1", SpreadsheetData::default())], - )); - *self.model.borrow_mut() = model; - self.show_dashboard = false; - self.initialized = false; - self.view.redraw(cx); - } - crate::dashboard::DashboardAction::OpenFile(filename) => { - if let Some(model) = self.load_workbook_from_file(&filename) { - *self.model.borrow_mut() = model; - self.show_dashboard = false; - self.initialized = false; - self.view.redraw(cx); - } - } - crate::dashboard::DashboardAction::BackToDashboard => { - self.show_dashboard = true; - self.view.redraw(cx); - } - crate::dashboard::DashboardAction::ImportExcel => { - self.pick_excel_file(cx); - } - } - } - - /// Open the platform file picker for an Excel file. - /// - /// `robius-file-picker` rather than makepad's own dialog, which is - /// implemented on macOS only — the Linux and Android backends never - /// handle `CxOsOp::SelectFileDialog`, so the button would do nothing - /// at all on the platforms this repo targets. - /// - /// The callback runs off the UI thread with no `Cx`, so it parks the - /// chosen path; `drain_excel_import` applies it on the next - /// `Event::Signal`. - fn pick_excel_file(&mut self, cx: &mut Cx) { - use robius_file_picker::FileDialog; - - let result = FileDialog::new() - .set_title("Import Excel File") - .add_filter("Excel files", &["xls", "xlsx", "xlsb"]) - .pick_file(|outcome| match outcome { - Ok(Some(picked)) => match picked.path() { - Some(path) => park_import(ExcelImportOutcome::Picked(path.to_path_buf())), - None => park_import(ExcelImportOutcome::Failed( - "That file has no local path this app can read".to_string(), - )), - }, - // Cancelled: say nothing and change nothing. - Ok(None) => {} - Err(e) => park_import(ExcelImportOutcome::Failed(format!( - "File picker failed: {e}" - ))), - }); - - if let Err(e) = result { - self.label(cx, ids!(status_label)) - .set_text(cx, &format!("Could not open the file picker: {e}")); - } - } - - /// Apply a parked file-picker outcome, if any. Called on `Event::Signal`. - fn drain_excel_import(&mut self, cx: &mut Cx) { - let outcome = { - let mut guard = PENDING_EXCEL_IMPORT - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - guard.take() - }; - let Some(outcome) = outcome else { - return; - }; - match outcome { - ExcelImportOutcome::Picked(path) => self.finish_excel_import(cx, &path), - ExcelImportOutcome::Failed(message) => { - self.label(cx, ids!(status_label)).set_text(cx, &message); - } - } - } - - /// Finish an Excel import from a locally readable path. - fn finish_excel_import(&mut self, cx: &mut Cx, path: &std::path::Path) { - match spreadsheet_engine::import_workbook_from_path(path) { - Ok((sheets, active)) => { - *self.model.borrow_mut() = - crate::model::WorkspaceModel::from_parts(sheets, active); - self.show_dashboard = false; - self.initialized = false; - self.view.redraw(cx); - } - Err(e) => { - self.label(cx, ids!(status_label)) - .set_text(cx, &format!("Could not import {}: {e}", path.display())); - } - } - } - - /// Load a workbook from the generated directory by filename. - fn load_workbook_from_file(&self, filename: &str) -> Option { - use spreadsheet_engine::persistence::sheet_generated_dir_path; - let dir = sheet_generated_dir_path(); - let path = dir.join(filename); - let contents = std::fs::read_to_string(&path).ok()?; - let (sheets, active) = spreadsheet_engine::workbook::deserialize_workbook(&contents)?; - Some(crate::model::WorkspaceModel::from_parts(sheets, active)) - } - fn apply_grid_intents(&mut self, cx: &mut Cx) { let mut changed = false; if let Some(mut grid) = self @@ -1270,12 +1057,5 @@ script_mod! { tab_inactive_color: #x1e1e30 tab_active_text_color: #xffffff tab_inactive_text_color: #x8a8aa5 - - // -- Dashboard (C2: file-list overlay, shown on first launch) -- - // Hidden when the editor is active. The workspace toggles - // `visible` on this widget based on `show_dashboard`. - dashboard := mod.widgets.SpreadsheetDashboard { - width: Fill, height: Fill, visible: true - } } } diff --git a/crates/apps/streem/Cargo.toml b/crates/apps/streem/Cargo.toml index e496a58..647f7a5 100644 --- a/crates/apps/streem/Cargo.toml +++ b/crates/apps/streem/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../../nigig-core" } nigig-uikit = { path = "../../nigig-uikit" } serde = { version = "1", features = ["derive"] } diff --git a/crates/apps/theming/Cargo.toml b/crates/apps/theming/Cargo.toml index 06f3415..1d0d897 100644 --- a/crates/apps/theming/Cargo.toml +++ b/crates/apps/theming/Cargo.toml @@ -21,7 +21,7 @@ nigig-core = { path = "../../nigig-core" } # matches. If nigig-core uses a path dep, match it here. If it uses git, # match that. # makepad-widgets = { git = "https://github.com/makepad/makepad.git", branch = "dev" } -makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "4a166606c08867de216125ae34909bc0a1115251"} +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} # ── Robius integration ─────────────────────────────────────────────────── # robius-use-makepad sets up the right Makepad feature flags for diff --git a/crates/nigig-core/Cargo.toml b/crates/nigig-core/Cargo.toml index c8f661b..eb6dac7 100644 --- a/crates/nigig-core/Cargo.toml +++ b/crates/nigig-core/Cargo.toml @@ -21,7 +21,7 @@ keystore = ["dep:keyring"] [dependencies] matrix_client = { path = "../matrix_client", default-features = false } nigig-system-prefs = { path = "../nigig-system-prefs" } -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/nigig-core/src/network/mod.rs b/crates/nigig-core/src/network/mod.rs index 8221a49..0561840 100644 --- a/crates/nigig-core/src/network/mod.rs +++ b/crates/nigig-core/src/network/mod.rs @@ -1,46 +1,62 @@ //! Network connectivity monitoring. //! -//! Provides SIM card / cellular network status for USSD payment flows. -//! USSD rides the cellular signalling channel — it does not need Wi-Fi -//! or mobile data, but it does need a registered SIM and cellular signal. -//! -//! On Android this queries `TelephonyManager` via JNI (through -//! `robius_ussd::sim_network_status`) to check SIM state, airplane mode, -//! and network registration. On desktop platforms it returns `Unknown`. +//! Provides a simple connectivity check that works cross-platform: +//! - On Android: checks via JNI `ConnectivityManager` (stubbed) +//! - On Desktop: TCP connect to well-known host #![allow(unused_imports, dead_code)] use chrono::Utc; -/// The type of cellular connectivity detected. +/// The type of network connectivity detected. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Connectivity { - /// SIM card is present and registered on a cellular network. - /// USSD should work. - Active, - /// No SIM card is inserted, or the SIM state is unknown. - NoSim, - /// SIM is present but not registered on any cellular network - /// (e.g. inside a concrete building with no signal, or airplane mode). - NoSignal, - /// Could not determine status (platform not supported). - Unknown, + /// No network interface is available. + None, + /// Cellular data (with approximate signal quality). + Cellular(SignalQuality), + /// Wi-Fi + Wifi(SignalQuality), + /// Ethernet or other high-speed connection. + Ethernet, } impl Connectivity { /// A short human-readable label for this connectivity type. pub fn label(&self) -> &'static str { match self { - Connectivity::Active => "SIM Ready", - Connectivity::NoSim => "No SIM", - Connectivity::NoSignal => "No Signal", - Connectivity::Unknown => "Unknown", + Connectivity::None => "No Network", + Connectivity::Cellular(_) => "Cellular", + Connectivity::Wifi(_) => "Wi-Fi", + Connectivity::Ethernet => "Ethernet", } } - /// Returns `true` if USSD payments should be possible. + /// Returns `true` if the device has any working connectivity. pub fn is_connected(&self) -> bool { - matches!(self, Connectivity::Active) + !matches!(self, Connectivity::None) + } +} + +/// Approximate cellular or Wi-Fi signal quality. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SignalQuality { + None, + Poor, + Fair, + Good, + Excellent, +} + +impl SignalQuality { + pub fn label(&self) -> &'static str { + match self { + SignalQuality::None => "", + SignalQuality::Poor => " (weak)", + SignalQuality::Fair => " (fair)", + SignalQuality::Good => " (good)", + SignalQuality::Excellent => " (strong)", + } } } @@ -59,7 +75,7 @@ static LAST_STATUS: std::sync::OnceLock> = /// Spawns a background thread that checks connectivity every 30s. pub fn init() { let _ = LAST_STATUS.set(std::sync::Mutex::new(NetworkStatus { - connectivity: Connectivity::Unknown, + connectivity: Connectivity::None, checked_at_ms: 0, })); @@ -90,7 +106,7 @@ pub fn current() -> NetworkStatus { .get() .and_then(|mtx| mtx.lock().ok().map(|s| s.clone())) .unwrap_or(NetworkStatus { - connectivity: Connectivity::Unknown, + connectivity: Connectivity::None, checked_at_ms: 0, }) } @@ -98,24 +114,30 @@ pub fn current() -> NetworkStatus { /// Format the current network status as a short display string /// suitable for a UI label. pub fn display_string() -> String { - current().connectivity.label().to_string() + let ns = current(); + let icon = match ns.connectivity { + Connectivity::None => "", + Connectivity::Cellular(_) => "", + Connectivity::Wifi(_) => "", + Connectivity::Ethernet => "", + }; + let label = ns.connectivity.label(); + let signal = match ns.connectivity { + Connectivity::Cellular(q) | Connectivity::Wifi(q) => q.label(), + _ => "", + }; + format!("{}{}{}", icon, label, signal) } /// Perform an actual connectivity check. -/// -/// Delegates to `robius_ussd::sim_network_status()` which queries -/// `TelephonyManager` on Android (SIM state, airplane mode, network -/// registration). On non-Android platforms returns `Unknown`. fn check_now() -> NetworkStatus { let checked_at_ms = Utc::now().timestamp_millis(); - - let connectivity = match robius_ussd::sim_network_status() { - robius_ussd::SimNetworkStatus::Active => Connectivity::Active, - robius_ussd::SimNetworkStatus::NoSim => Connectivity::NoSim, - robius_ussd::SimNetworkStatus::NoSignal => Connectivity::NoSignal, - robius_ussd::SimNetworkStatus::AirplaneMode => Connectivity::NoSignal, - robius_ussd::SimNetworkStatus::Unknown => Connectivity::Unknown, + let connectivity = match std::net::TcpStream::connect_timeout( + &"8.8.8.8:53".parse().unwrap(), + std::time::Duration::from_secs(5), + ) { + Ok(_) => Connectivity::Ethernet, + Err(_) => Connectivity::None, }; - NetworkStatus { connectivity, checked_at_ms } } diff --git a/crates/nigig-core/src/syncing.rs b/crates/nigig-core/src/syncing.rs index f96cfb3..d915663 100644 --- a/crates/nigig-core/src/syncing.rs +++ b/crates/nigig-core/src/syncing.rs @@ -981,25 +981,6 @@ fn push_action(action: DeviceAction) { SignalToUI::set_ui_signal(); } -/// Encode raw RGB (3 bytes per pixel, row-major) into PNG bytes in memory, -/// reusing the tested `image` encoder. No path/IO side effects. -pub fn encode_png_rgb( - width: usize, - height: usize, - rgb_data: &[u8], -) -> std::io::Result> { - use image::{ImageBuffer, ImageFormat, Rgb}; - let img: ImageBuffer, Vec> = - ImageBuffer::from_raw(width as u32, height as u32, rgb_data.to_vec()) - .ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid image dimensions") - })?; - let mut out = std::io::Cursor::new(Vec::new()); - img.write_to(&mut out, ImageFormat::Png) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; - Ok(out.into_inner()) -} - fn save_png_image(width: usize, height: usize, rgb_data: &[u8], file_name: &str) -> std::io::Result { let dir = crate::dir::app_data_dir().join("camera"); fs::create_dir_all(&dir)?; @@ -1010,8 +991,13 @@ fn save_png_image(width: usize, height: usize, rgb_data: &[u8], file_name: &str) file_name.to_string() }; let path = dir.join(&clean_name); - let bytes = encode_png_rgb(width, height, rgb_data)?; - fs::write(&path, bytes)?; + // Encode as PNG using the image crate + use image::{ImageBuffer, ImageFormat, Rgb}; + let img: ImageBuffer, Vec> = + ImageBuffer::from_raw(width as u32, height as u32, rgb_data.to_vec()) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid image dimensions"))?; + img.save_with_format(&path, ImageFormat::Png) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; Ok(path.display().to_string()) } diff --git a/crates/nigig-uikit/Cargo.toml b/crates/nigig-uikit/Cargo.toml index 3690eaf..048508b 100644 --- a/crates/nigig-uikit/Cargo.toml +++ b/crates/nigig-uikit/Cargo.toml @@ -5,7 +5,7 @@ version = "0.1.0" edition = "2021" [dependencies] -makepad-widgets = { workspace = true } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc"} nigig-core = { path = "../nigig-core" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/nigig-uikit/src/shared/contact_picker.rs b/crates/nigig-uikit/src/shared/contact_picker.rs index 34b1a09..1bdfe83 100644 --- a/crates/nigig-uikit/src/shared/contact_picker.rs +++ b/crates/nigig-uikit/src/shared/contact_picker.rs @@ -1,6 +1,5 @@ use makepad_widgets::*; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{LazyLock, Mutex}; +use std::sync::Mutex; use crate::shared::filter_input_bar::FilterInputBarWidgetExt; /// Actions emitted by the ContactPicker widget @@ -19,61 +18,24 @@ impl ActionDefaultRef for ContactPickerAction { } } -// ─── Prefetch infrastructure ─────────────────────────────────────────────── -// -// prefetch_contacts() spawns a worker thread that loads the full phonebook -// into PREFETCHED_CONTACTS and signals the UI via SignalToUI. The worker -// never touches widgets — it only writes to the static and sets a signal. -// -// ContactPicker checks PREFETCHED_CONTACTS in handle_event (not draw_walk) -// so no cloning happens on the render thread. +/// Global prefetch cache: populated on app startup so the contact picker +/// is instant when first opened, instead of spawning a background thread. +pub static PREFETCHED_CONTACTS: Mutex>> = Mutex::new(None); -/// Result of the background prefetch. Populated by the worker thread. -static PREFETCHED_CONTACTS: LazyLock>>> = - LazyLock::new(|| Mutex::new(None)); - -/// True while the prefetch worker is running. -static PREFETCH_IN_FLIGHT: AtomicBool = AtomicBool::new(false); - -/// Spawn a background thread to load the phonebook into `PREFETCHED_CONTACTS`. -/// Safe to call multiple times — guarded by `PREFETCH_IN_FLIGHT`. -/// Signals the UI via `SignalToUI` when done. +/// Spawn a background thread to load contacts into `PREFETCHED_CONTACTS`. +/// Call once at app startup (e.g. from `lazy_init`). pub fn prefetch_contacts() { - // list_contacts() queries ContactsProvider via JNI on a background thread. - // Without READ_CONTACTS the Java side raises a SecurityException that - // escapes catch_unwind as an uncaught FATAL EXCEPTION (killing the process), - // so only spawn the worker when the permission is already held. - #[cfg(target_os = "android")] - { - match robius_contacts::has_permission(robius_contacts::Permission::ReadContacts) { - Ok(true) => {} - Ok(false) => { - log!("[Contacts] prefetch skipped: READ_CONTACTS not granted"); - return; - } - Err(e) => { - log!("[Contacts] prefetch skipped: permission check failed: {e:?}"); - return; - } + std::thread::spawn(|| { + let contacts = robius_contacts::list_contacts().unwrap_or_default(); + if let Ok(mut lock) = PREFETCHED_CONTACTS.lock() { + *lock = Some(contacts); } - } - if PREFETCH_IN_FLIGHT.swap(true, Ordering::Relaxed) { - return; - } - std::thread::spawn(move || { - // catch_unwind: list_contacts() calls ContactsProvider via JNI on a - // background thread. Without READ_CONTACTS permission the Java side - // throws a SecurityException that escapes JNI error handling and - // becomes an uncaught FATAL EXCEPTION, killing the process. - let contacts = std::panic::catch_unwind(|| robius_contacts::list_contacts()) - .unwrap_or_else(|_| Ok(Vec::new())) - .unwrap_or_default(); - *PREFETCHED_CONTACTS.lock().unwrap() = Some(contacts); - PREFETCH_IN_FLIGHT.store(false, Ordering::Relaxed); - SignalToUI::set_ui_signal(); }); } +// Global state to safely receive contacts from the background thread (fallback) +static PENDING_CONTACTS: Mutex>> = Mutex::new(None); + // ─── Contact Row Widget ───────────────────────────────────────────────────── #[derive(Clone, Debug, Default)] @@ -168,30 +130,47 @@ impl Widget for ContactPicker { // see TextInputAction::Changed in the next Event::Actions frame. cx.extend_actions(actions); - // ── Check prefetch result (on Signal or first event frame) ────── - // The prefetch worker signals via SignalToUI when done, but if the - // picker opens after the signal was already consumed by another - // widget, we also check on every event frame (cheap — one mutex - // lock, one Option check). - if self.all_contacts.is_empty() { - if let Ok(lock) = PREFETCHED_CONTACTS.lock() { - if let Some(ref cached) = *lock { - self.all_contacts = cached.clone(); - self.is_loading = false; - let query = self - .view - .filter_input_bar(cx, ids!(search_bar)) - .text(cx); - self.filter_contacts(&query); - self.view.redraw(cx); - } + // Check if background loading finished + if let Ok(mut lock) = PENDING_CONTACTS.lock() { + if let Some(contacts) = lock.take() { + self.all_contacts = contacts; + self.is_loading = false; + let query = self + .view + .filter_input_bar(cx, ids!(search_bar)) + .text(cx); + self.filter_contacts(&query); + self.view.redraw(cx); } } } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - // Only check the already-populated all_contacts — never spawn - // threads or clone large data on the render thread. + // Check prefetched cache first (populated at startup), then fall back + // to spawning our own background thread. + if self.all_contacts.is_empty() && !self.is_loading { + if let Ok(lock) = PREFETCHED_CONTACTS.lock() { + if let Some(ref cached) = *lock { + self.all_contacts = cached.clone(); + let query = self + .view + .filter_input_bar(cx, ids!(search_bar)) + .text(cx); + self.filter_contacts(&query); + } + } + } + if self.all_contacts.is_empty() && !self.is_loading { + self.is_loading = true; + std::thread::spawn(|| { + let contacts = robius_contacts::list_contacts().unwrap_or_default(); + if let Ok(mut lock) = PENDING_CONTACTS.lock() { + *lock = Some(contacts); + } + Cx::post_action(ContactPickerAction::CloseRequested); + }); + } + while let Some(step) = self.view.draw_walk(cx, scope, walk).step() { if let Some(mut list) = step.borrow_mut::() { let total = if self.is_loading { 1 } else { self.filtered_contacts.len() }; diff --git a/crates/pageflipnav/Cargo.toml b/crates/pageflipnav/Cargo.toml index 65157ec..f0028ee 100644 --- a/crates/pageflipnav/Cargo.toml +++ b/crates/pageflipnav/Cargo.toml @@ -19,8 +19,25 @@ edition = "2021" ## ## Turning it off for a build: ## cargo build -p pageflipnav --no-default-features --features native -default = ["native"] +default = ["native", "demo"] native = ["dep:tokio", "dep:reqwest", "nigig-core/native"] +## Enable automated *334# USSD payment in the shipped app. +## +## This is the crate that produces the APK, so without this the `demo` flag +## on nigig-pay/nigig-mpesa is unreachable: building pageflipnav could never +## turn the automation on, and the Pay sheet always reported that dispatch +## was unavailable with no way to change it. +## +## Off by default (review items 0.1/0.3): a default APK is a tracker that +## never dials USSD and never captures an M-Pesa PIN. +## +## Enabling it turns on AccessibilityService-driven USSD automation, which +## remains an unresolved Google Play policy risk (review item 5.2, +## REVIEWS/adr/0007). Use it for your own device testing; do not publish an +## APK built with it until that decision is made. +## +## cargo run -p pageflipnav --features demo +demo = ["nigig-pay/demo", "nigig-mpesa/demo"] [profile.small] inherits = "release" @@ -31,7 +48,7 @@ panic = 'abort' strip = true [dependencies] -makepad-widgets = { workspace = true, features = ["test", "serde", "maps"] } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc", default-features = false, features = ["test", "serde", "maps"] } robius-use-makepad = "0.1.1" robius-open = { git = "https://github.com/project-robius/robius", rev = "b766e62b0600f5d2ee21cc6995648346fc277bd8" } robius-directories = { git = "https://github.com/project-robius/robius", rev = "b766e62b0600f5d2ee21cc6995648346fc277bd8" } @@ -85,4 +102,3 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus getrandom = { version = "0.2", features = ["js"] } [dev-dependencies] -makepad-test = { workspace = true } \ No newline at end of file diff --git a/crates/pageflipnav/resources/android/AndroidManifest.xml b/crates/pageflipnav/resources/android/AndroidManifest.xml deleted file mode 100644 index 94ec654..0000000 --- a/crates/pageflipnav/resources/android/AndroidManifest.xml +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crates/pageflipnav/resources/android/java/robius/sms/SmsAlarmReceiver.java b/crates/pageflipnav/resources/android/java/robius/sms/SmsAlarmReceiver.java deleted file mode 100644 index f06488d..0000000 --- a/crates/pageflipnav/resources/android/java/robius/sms/SmsAlarmReceiver.java +++ /dev/null @@ -1,49 +0,0 @@ -package robius.sms; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.telephony.SmsManager; - -import java.util.ArrayList; - -public class SmsAlarmReceiver extends BroadcastReceiver { - private static final String PREFS_NAME = "robius_sms_schedules"; - - @Override - public void onReceive(Context context, Intent intent) { - int id = intent.getIntExtra("schedule_id", -1); - if (id <= 0) { - return; - } - - SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - String prefix = "schedule_" + id + "_"; - - // E3: recipient and body are stored encrypted (SmsScheduleCrypto). - // decrypt() returns null for a value this build cannot read -- - // wrong key after a reinstall, tampering, or a row written by an - // older plaintext build -- and null is handled the same as - // absent. Sending a garbled body would be worse than not sending. - String recipient = SmsScheduleCrypto.decrypt(prefs.getString(prefix + "recipient", null)); - String body = SmsScheduleCrypto.decrypt(prefs.getString(prefix + "body", null)); - - if (recipient == null || body == null) { - return; - } - - // A5, on the scheduled path. The Rust send path was fixed to use - // divideMessage/sendMultipartTextMessage, but this receiver still - // called sendTextMessage directly -- so a scheduled message over - // 160 GSM-7 characters (or 70 with any emoji) was silently - // truncated or dropped, with nothing to report it. - SmsManager smsManager = SmsManager.getDefault(); - ArrayList parts = smsManager.divideMessage(body); - if (parts != null && parts.size() > 1) { - smsManager.sendMultipartTextMessage(recipient, null, parts, null, null); - } else { - smsManager.sendTextMessage(recipient, null, body, null, null); - } - } -} \ No newline at end of file diff --git a/crates/pageflipnav/resources/android/java/robius/sms/SmsBootReceiver.java b/crates/pageflipnav/resources/android/java/robius/sms/SmsBootReceiver.java deleted file mode 100644 index 0466b3c..0000000 --- a/crates/pageflipnav/resources/android/java/robius/sms/SmsBootReceiver.java +++ /dev/null @@ -1,19 +0,0 @@ -package robius.sms; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; - -public class SmsBootReceiver extends BroadcastReceiver { - private static native void rustRestoreSchedules(Context context); - - @Override - public void onReceive(Context context, Intent intent) { - if (intent != null && Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { - try { - rustRestoreSchedules(context); - } catch (UnsatisfiedLinkError ignored) { - } - } - } -} diff --git a/crates/pageflipnav/resources/android/java/robius/sms/SmsScheduleCrypto.java b/crates/pageflipnav/resources/android/java/robius/sms/SmsScheduleCrypto.java deleted file mode 100644 index 36efa29..0000000 --- a/crates/pageflipnav/resources/android/java/robius/sms/SmsScheduleCrypto.java +++ /dev/null @@ -1,119 +0,0 @@ -package robius.sms; - -import android.security.keystore.KeyGenParameterSpec; -import android.security.keystore.KeyProperties; -import android.util.Base64; - -import java.security.KeyStore; - -import javax.crypto.Cipher; -import javax.crypto.KeyGenerator; -import javax.crypto.SecretKey; -import javax.crypto.spec.GCMParameterSpec; - -/** - * Envelope encryption for scheduled-SMS payloads (bug E3). - * - * Pending schedules are held in SharedPreferences so SmsAlarmReceiver can - * read them back when the alarm fires, and so they survive a reboot. - * MODE_PRIVATE is the right primitive -- the file is UID-scoped -- but the - * contents were plaintext, so the recipient and message body of every - * pending schedule sat readable on disk and were swept into cloud backup - * by default. SMS bodies are the same asset class as the inbox: - * THREAT_MODEL.md T-I4. - * - * Deliberately uses the PLATFORM keystore rather than - * androidx.security.EncryptedSharedPreferences. androidx is a Gradle - * dependency and this crate compiles its Java with bare javac against - * android.jar (see build.rs), so pulling it in would mean either a Gradle - * build or vendoring a jar. AndroidKeyStore + javax.crypto are both in - * android.jar and give the property that matters: the AES key is - * generated inside the keystore, is not exportable, and is scoped to this - * app. An attacker with the prefs file but not the keystore gets - * ciphertext. - * - * Format: Base64( 12-byte IV || GCM ciphertext+tag ). - * AES-256-GCM, fresh random IV per encryption, 128-bit tag. - */ -public final class SmsScheduleCrypto { - private static final String KEYSTORE = "AndroidKeyStore"; - private static final String KEY_ALIAS = "robius_sms_schedule_key"; - private static final String TRANSFORM = "AES/GCM/NoPadding"; - private static final int IV_BYTES = 12; - private static final int TAG_BITS = 128; - - private SmsScheduleCrypto() {} - - private static synchronized SecretKey key() throws Exception { - KeyStore ks = KeyStore.getInstance(KEYSTORE); - ks.load(null); - - KeyStore.Entry entry = ks.getEntry(KEY_ALIAS, null); - if (entry instanceof KeyStore.SecretKeyEntry) { - return ((KeyStore.SecretKeyEntry) entry).getSecretKey(); - } - - KeyGenerator kg = KeyGenerator.getInstance( - KeyProperties.KEY_ALGORITHM_AES, KEYSTORE); - kg.init(new KeyGenParameterSpec.Builder( - KEY_ALIAS, - KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(256) - // NOT setUserAuthenticationRequired: an alarm fires while the - // device may be locked, and the receiver must decrypt without - // a user present. This protects data at rest against another - // app or an extracted backup -- the threat in scope -- not - // against someone holding an unlocked device. - .build()); - return kg.generateKey(); - } - - /** Returns Base64(IV||ciphertext), or null if encryption is unavailable. */ - public static String encrypt(String plaintext) { - if (plaintext == null) { - return null; - } - try { - Cipher cipher = Cipher.getInstance(TRANSFORM); - cipher.init(Cipher.ENCRYPT_MODE, key()); - byte[] iv = cipher.getIV(); - byte[] ct = cipher.doFinal(plaintext.getBytes("UTF-8")); - - byte[] out = new byte[iv.length + ct.length]; - System.arraycopy(iv, 0, out, 0, iv.length); - System.arraycopy(ct, 0, out, iv.length, ct.length); - return Base64.encodeToString(out, Base64.NO_WRAP); - } catch (Exception e) { - // The caller treats null as "could not protect this value" - // and refuses to store it. Failing closed is correct: - // falling back to plaintext would silently reintroduce T-I4. - return null; - } - } - - /** Returns the plaintext, or null if the value cannot be decrypted. */ - public static String decrypt(String encoded) { - if (encoded == null) { - return null; - } - try { - byte[] raw = Base64.decode(encoded, Base64.NO_WRAP); - if (raw.length <= IV_BYTES) { - return null; - } - byte[] iv = new byte[IV_BYTES]; - System.arraycopy(raw, 0, iv, 0, IV_BYTES); - - Cipher cipher = Cipher.getInstance(TRANSFORM); - cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(TAG_BITS, iv)); - byte[] pt = cipher.doFinal(raw, IV_BYTES, raw.length - IV_BYTES); - return new String(pt, "UTF-8"); - } catch (Exception e) { - // Wrong key (app reinstalled, keystore cleared), tampered - // ciphertext, or a value written by an older plaintext build. - return null; - } - } -} diff --git a/crates/pageflipnav/resources/android/java/robius/trigger/BootReceiver.java b/crates/pageflipnav/resources/android/java/robius/trigger/BootReceiver.java deleted file mode 100644 index 8b7ac53..0000000 --- a/crates/pageflipnav/resources/android/java/robius/trigger/BootReceiver.java +++ /dev/null @@ -1,33 +0,0 @@ -package robius.trigger; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Build; - -/** - * Restarts the {@link SmsForegroundService} after device reboot - * if SMS listening was enabled before shutdown. - */ -public class BootReceiver extends BroadcastReceiver { - - private static final String PREFS_NAME = "robius_trigger_prefs"; - private static final String KEY_ENABLED = "sms_listener_enabled"; - - @Override - public void onReceive(Context context, Intent intent) { - if (intent == null) return; - if (!Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) return; - - SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - if (prefs.getBoolean(KEY_ENABLED, false)) { - Intent svc = new Intent(context, SmsForegroundService.class); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(svc); - } else { - context.startService(svc); - } - } - } -} diff --git a/crates/pageflipnav/resources/android/java/robius/trigger/SmsForegroundService.java b/crates/pageflipnav/resources/android/java/robius/trigger/SmsForegroundService.java deleted file mode 100644 index 206dfcd..0000000 --- a/crates/pageflipnav/resources/android/java/robius/trigger/SmsForegroundService.java +++ /dev/null @@ -1,73 +0,0 @@ -package robius.trigger; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.Intent; -import android.os.Build; -import android.os.IBinder; - -/** - * Foreground service that keeps the app process alive so - * {@link SmsReceiver} can receive SMS reliably. - */ -public class SmsForegroundService extends Service { - - private static final String CHANNEL_ID = "robius_trigger_sms"; - private static final int NOTIFICATION_ID = 9101; - - @Override - public IBinder onBind(Intent intent) { return null; } - - @Override - public void onCreate() { - super.onCreate(); - createNotificationChannel(); - startForeground(NOTIFICATION_ID, buildNotification()); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - return START_STICKY; - } - - private void createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel channel = new NotificationChannel( - CHANNEL_ID, - "SMS Listener", - NotificationManager.IMPORTANCE_LOW - ); - channel.setShowBadge(false); - channel.setSound(null, null); - channel.enableVibration(false); - NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); - if (nm != null) nm.createNotificationChannel(channel); - } - } - - private Notification buildNotification() { - String pkg = getPackageName(); - Intent launchIntent = getPackageManager().getLaunchIntentForPackage(pkg); - PendingIntent pi = PendingIntent.getActivity( - this, 0, launchIntent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE - ); - Notification.Builder builder; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - builder = new Notification.Builder(this, CHANNEL_ID); - } else { - builder = new Notification.Builder(this); - } - return builder - .setContentTitle("SMS Listener") - .setContentText("Listening for incoming messages") - .setSmallIcon(android.R.drawable.ic_dialog_info) - .setContentIntent(pi) - .setOngoing(true) - .setCategory(Notification.CATEGORY_SERVICE) - .build(); - } -} diff --git a/crates/pageflipnav/resources/android/java/robius/trigger/SmsReceiver.java b/crates/pageflipnav/resources/android/java/robius/trigger/SmsReceiver.java deleted file mode 100644 index 9f4200f..0000000 --- a/crates/pageflipnav/resources/android/java/robius/trigger/SmsReceiver.java +++ /dev/null @@ -1,43 +0,0 @@ -package robius.trigger; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.provider.Telephony; -import android.telephony.SmsMessage; - -/** - * Receives incoming SMS and forwards every message to the Rust-native - * callback {@link #rustOnSmsReceived}. All sender filtering is done - * on the Rust side so the app controls what to act on. - */ -public class SmsReceiver extends BroadcastReceiver { - - private static native void rustOnSmsReceived(String body, String sender, long receivedAtMs); - - @Override - public void onReceive(Context context, Intent intent) { - if (intent == null) return; - if (!Telephony.Sms.Intents.SMS_RECEIVED_ACTION.equals(intent.getAction())) return; - - SmsMessage[] messages = Telephony.Sms.Intents.getMessagesFromIntent(intent); - if (messages == null || messages.length == 0) return; - - String sender = messages[0].getOriginatingAddress(); - if (sender == null) return; - - StringBuilder bodyBuilder = new StringBuilder(); - for (SmsMessage msg : messages) { - String b = msg.getMessageBody(); - if (b != null) bodyBuilder.append(b); - } - String body = bodyBuilder.toString().trim(); - if (body.isEmpty()) return; - - try { - rustOnSmsReceived(body, sender, System.currentTimeMillis()); - } catch (UnsatisfiedLinkError ignored) { - // Native not yet registered. - } - } -} diff --git a/crates/pageflipnav/resources/android/java/robius/ussd/UssdAccessibilityService.java b/crates/pageflipnav/resources/android/java/robius/ussd/UssdAccessibilityService.java deleted file mode 100644 index c4506f2..0000000 --- a/crates/pageflipnav/resources/android/java/robius/ussd/UssdAccessibilityService.java +++ /dev/null @@ -1,631 +0,0 @@ -package robius.ussd; - -import android.accessibilityservice.AccessibilityService; -import android.accessibilityservice.AccessibilityServiceInfo; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.provider.Settings; -import android.text.TextUtils; -import android.view.accessibility.AccessibilityEvent; -import android.view.accessibility.AccessibilityNodeInfo; -import android.view.accessibility.AccessibilityWindowInfo; - -import java.util.List; - -/** - * Drives the system-rendered M-Pesa USSD dialog (the dialog drawn by - * {@code com.android.phone}) via AccessibilityService. Mirrors the - * PesaMirror Kotlin reference but is callable from Rust via JNI. - * - *

State machine and timing constants match PesaMirror so existing - * PesaMirror trigger code can be reused.

- * - *

The Rust side registers a native callback {@link #rustOnUssdEvent} - * (declared {@code native} below) and pushes events back through it.

- */ -public class UssdAccessibilityService extends AccessibilityService { - - private static final String PREFS_NAME = "robius_ussd_session"; - - private static final String KEY_PENDING = "ussd_pending"; - private static final String KEY_STATE = "ussd_state"; - private static final String KEY_MODE = "ussd_mode"; - private static final String KEY_AMOUNT = "ussd_amount"; - private static final String KEY_PIN = "ussd_pin"; - private static final String KEY_PHONE = "ussd_phone"; - private static final String KEY_TILL = "ussd_till"; - private static final String KEY_BUSINESS = "ussd_business"; - private static final String KEY_ACCOUNT = "ussd_account"; - private static final String KEY_AGENT = "ussd_agent"; - private static final String KEY_STORE = "ussd_store"; - private static final String KEY_CONFIRM = "confirm_send"; - - public static final String MODE_SEND_MONEY = "SEND_MONEY"; - public static final String MODE_POCHI = "POCHI"; - public static final String MODE_PAYBILL = "PAYBILL"; - public static final String MODE_TILL = "TILL"; - public static final String MODE_WITHDRAW = "WITHDRAW"; - - // State machine labels (kept as plain Strings to match PesaMirror). - private static final String STATE_SM_1 = "SM_1"; - private static final String STATE_SM_2 = "SM_2"; - private static final String STATE_SM_PHONE = "SM_PHONE"; - private static final String STATE_SM_AMOUNT = "SM_AMOUNT"; - private static final String STATE_SM_PIN = "SM_PIN"; - - private static final String STATE_POCHI_1 = "POCHI_1"; - private static final String STATE_POCHI_3 = "POCHI_3"; - private static final String STATE_POCHI_PHONE = "POCHI_PHONE"; - private static final String STATE_POCHI_AMOUNT= "POCHI_AMOUNT"; - private static final String STATE_POCHI_PIN = "POCHI_PIN"; - - private static final String STATE_TILL_6 = "TILL_6"; - private static final String STATE_TILL_2 = "TILL_2"; - private static final String STATE_TILL_NUM = "TILL_NUM"; - private static final String STATE_TILL_AMOUNT = "TILL_AMOUNT"; - private static final String STATE_TILL_PIN = "TILL_PIN"; - - private static final String STATE_PB_6 = "PB_6"; - private static final String STATE_PB_1 = "PB_1"; - private static final String STATE_PB_BUSINESS = "PB_BUSINESS"; - private static final String STATE_PB_ACCOUNT = "PB_ACCOUNT"; - private static final String STATE_PB_AMOUNT = "PB_AMOUNT"; - private static final String STATE_PB_PIN = "PB_PIN"; - - private static final String STATE_WD_2 = "WD_2"; - private static final String STATE_WD_1 = "WD_1"; - private static final String STATE_WD_AGENT = "WD_AGENT"; - private static final String STATE_WD_STORE = "WD_STORE"; - private static final String STATE_WD_AMOUNT = "WD_AMOUNT"; - private static final String STATE_WD_PIN = "WD_PIN"; - - private static final String STATE_CONFIRM_1 = "CONFIRM_1"; - private static final String STATE_CONFIRM_CANCEL= "CONFIRM_CANCEL"; - private static final String STATE_DONE = "DONE"; - private static final String KEY_CONFIRM_RETRY = "confirm_retry"; - private static final String KEY_CANCEL_AT = "confirm_cancel_at"; - private static final int CONFIRM_MAX_RETRIES = 8; - - // Timing constants — match PesaMirror exactly. - private static final long INITIAL_DELAY_MS = 1200L; - private static final long STEP_DELAY_MS = 650L; - private static final long CONFIRM_DELAY_MS = 1500L; - private static final long CONFIRM_CANCEL_DELAY_MS = 5000L; - private static final long RESULT_THEN_CLOSE_DELAY_MS = 2000L; - private static final long TIMEOUT_MS = 60_000L; - - // Event type constants — must match the Rust side. - private static final int EVENT_DIAL_STARTED = 1; - private static final int EVENT_DIALOG_SHOWN = 2; - private static final int EVENT_STEP_COMPLETED = 3; - private static final int EVENT_RESULT_TEXT = 4; - private static final int EVENT_SESSION_ENDED = 5; - private static final int EVENT_ERROR = 6; - - // State int codes (mirrored to Rust via int_to_state). - private static final int STATE_INT_IDLE = 0; - private static final int STATE_INT_DIAL = 1; - private static final int STATE_INT_IN_PROGRESS = 2; - private static final int STATE_INT_AWAIT_CONFIRM = 3; - private static final int STATE_INT_DONE = 4; - private static final int STATE_INT_FAILED = 5; - - private static final List USSD_MARKERS = java.util.Arrays.asList( - "SEND", "CANCEL", "Send Money", "Withdraw Cash", "Yes", "No", "To continue" - ); - - private static final java.util.Set PHONE_PACKAGES = new java.util.HashSet<>(java.util.Arrays.asList( - "com.android.phone", - "com.google.android.dialer", - "com.android.dialer", - "com.samsung.android.dialer", - "com.google.android.contacts" - )); - - private final Handler handler = new Handler(Looper.getMainLooper()); - private long sessionStartMs = 0L; - - /** Native callback invoked by Rust side via JNI register_native_methods. */ - private static native void rustOnUssdEvent(int eventType, String text, float progress, int state); - - private final Runnable stepRunnable = this::processNextStep; - private final Runnable closeUssdAfterResultRunnable = this::closeUssdAfterResult; - private final Runnable timeoutRunnable = () -> fail("timeout"); - - @Override - protected void onServiceConnected() { - super.onServiceConnected(); - AccessibilityServiceInfo info = new AccessibilityServiceInfo(); - info.eventTypes = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED - | AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED; - info.feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC; - info.notificationTimeout = 100L; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - info.flags |= AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS; - } - setServiceInfo(info); - handler.postDelayed(() -> { - SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - if (prefs.getBoolean(KEY_PENDING, false)) { - String state = prefs.getString(KEY_STATE, ""); - if (!state.isEmpty() && !STATE_DONE.equals(state)) { - processNextStep(); - } - } - }, 60_000L); - } - - @Override - public void onAccessibilityEvent(AccessibilityEvent event) { - if (event == null) return; - String pkg = String.valueOf(event.getPackageName()); - if (!PHONE_PACKAGES.contains(pkg)) return; - - SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - boolean pending = prefs.getBoolean(KEY_PENDING, false); - String state = prefs.getString(KEY_STATE, ""); - if (!pending || STATE_DONE.equals(state)) return; - - if (sessionStartMs == 0L) { - sessionStartMs = System.currentTimeMillis(); - handler.postDelayed(timeoutRunnable, TIMEOUT_MS); - emitEvent(EVENT_DIALOG_SHOWN, null, 0f, STATE_INT_IN_PROGRESS); - } - - String firstState = firstStateForMode(prefs.getString(KEY_MODE, "")); - if (TextUtils.isEmpty(state)) { - prefs.edit().putString(KEY_STATE, firstState).apply(); - handler.postDelayed(stepRunnable, INITIAL_DELAY_MS); - } else { - // Event-driven like PesaMirror: re-run the state machine on every - // relevant window/event change so a transition that the periodic - // self-schedule may have missed (e.g. the post-PIN confirmation - // menu appearing) is picked up. - handler.removeCallbacks(stepRunnable); - handler.post(stepRunnable); - } - } - - @Override - public void onInterrupt() { - fail("interrupted"); - } - - private String firstStateForMode(String mode) { - if (MODE_SEND_MONEY.equals(mode)) return STATE_SM_1; - if (MODE_POCHI.equals(mode)) return STATE_POCHI_1; - if (MODE_TILL.equals(mode)) return STATE_TILL_6; - if (MODE_PAYBILL.equals(mode)) return STATE_PB_6; - if (MODE_WITHDRAW.equals(mode)) return STATE_WD_2; - return STATE_SM_1; - } - - private void processNextStep() { - SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - if (!prefs.getBoolean(KEY_PENDING, false)) return; - String state = prefs.getString(KEY_STATE, ""); - if (STATE_DONE.equals(state)) return; - - AccessibilityNodeInfo root = getUssdDialogRoot(); - if (root == null) { - handler.postDelayed(stepRunnable, STEP_DELAY_MS); - return; - } - - switch (state) { - // ── Send Money: 1 → 1 → phone → amount → pin ── - case STATE_SM_1: typeAndSend(prefs, root, "1", STATE_SM_2, 0.20f); break; - case STATE_SM_2: typeAndSend(prefs, root, "1", STATE_SM_PHONE, 0.30f); break; - case STATE_SM_PHONE: typeField(prefs, root, prefs.getString(KEY_PHONE, ""), STATE_SM_AMOUNT, 0.40f, "phone", "number"); break; - case STATE_SM_AMOUNT: typeField(prefs, root, prefs.getString(KEY_AMOUNT, ""), STATE_SM_PIN, 0.60f, "amount"); break; - case STATE_SM_PIN: finishPin(prefs, root); break; - - // ── Pochi: 1 → 3 → phone → amount → pin ── - case STATE_POCHI_1: typeAndSend(prefs, root, "1", STATE_POCHI_3, 0.20f); break; - case STATE_POCHI_3: typeAndSend(prefs, root, "3", STATE_POCHI_PHONE, 0.30f); break; - case STATE_POCHI_PHONE: typeField(prefs, root, prefs.getString(KEY_PHONE, ""), STATE_POCHI_AMOUNT, 0.40f, "phone", "number"); break; - case STATE_POCHI_AMOUNT:typeField(prefs, root, prefs.getString(KEY_AMOUNT, ""), STATE_POCHI_PIN, 0.60f, "amount"); break; - case STATE_POCHI_PIN: finishPin(prefs, root); break; - - // ── Till: 6 → 2 → till → amount → pin ── - case STATE_TILL_6: typeAndSend(prefs, root, "6", STATE_TILL_2, 0.20f); break; - case STATE_TILL_2: typeAndSend(prefs, root, "2", STATE_TILL_NUM, 0.30f); break; - case STATE_TILL_NUM: typeField(prefs, root, prefs.getString(KEY_TILL, ""), STATE_TILL_AMOUNT, 0.40f, "till", "goods"); break; - case STATE_TILL_AMOUNT: typeField(prefs, root, prefs.getString(KEY_AMOUNT, ""), STATE_TILL_PIN, 0.60f, "amount"); break; - case STATE_TILL_PIN: finishPin(prefs, root); break; - - // ── Paybill: 6 → 1 → business → account → amount → pin ── - case STATE_PB_6: typeAndSend(prefs, root, "6", STATE_PB_1, 0.20f); break; - case STATE_PB_1: typeAndSend(prefs, root, "1", STATE_PB_BUSINESS, 0.30f); break; - case STATE_PB_BUSINESS: typeField(prefs, root, prefs.getString(KEY_BUSINESS, ""), STATE_PB_ACCOUNT, 0.40f, "business", "paybill"); break; - case STATE_PB_ACCOUNT: typeField(prefs, root, prefs.getString(KEY_ACCOUNT, ""), STATE_PB_AMOUNT, 0.50f, "account"); break; - case STATE_PB_AMOUNT: typeField(prefs, root, prefs.getString(KEY_AMOUNT, ""), STATE_PB_PIN, 0.65f, "amount"); break; - case STATE_PB_PIN: finishPin(prefs, root); break; - - // ── Withdraw: 2 → 1 → agent → store → amount → pin ── - case STATE_WD_2: typeAndSend(prefs, root, "2", STATE_WD_1, 0.20f); break; - case STATE_WD_1: typeAndSend(prefs, root, "1", STATE_WD_AGENT, 0.30f); break; - case STATE_WD_AGENT: typeField(prefs, root, prefs.getString(KEY_AGENT, ""), STATE_WD_STORE, 0.40f, "agent"); break; - case STATE_WD_STORE: typeField(prefs, root, prefs.getString(KEY_STORE, ""), STATE_WD_AMOUNT, 0.50f, "store"); break; - case STATE_WD_AMOUNT: typeField(prefs, root, prefs.getString(KEY_AMOUNT, ""), STATE_WD_PIN, 0.65f, "amount"); break; - case STATE_WD_PIN: finishPin(prefs, root); break; - - // ── Post-PIN confirmation menu: choose an option ── - // M-Pesa shows a menu after the PIN is accepted, e.g. - // "Send KSh 200 to +2547…" "1. Send" "2. Cancel" - // It has NO editable input field, so we must CLICK the numbered - // option node rather than typing a digit into an EditText (typing - // alone was why this step never completed). - case STATE_CONFIRM_1: { - if (isPinDialogStillOpen(root)) { - handler.postDelayed(stepRunnable, STEP_DELAY_MS); - return; - } - if (selectMenuOption(root, "1", "Send", "Confirm", "Yes") - || typeInInputAndSend(root, "1")) { - SharedPreferences prefs_local = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - prefs_local.edit().putString(KEY_STATE, STATE_DONE).apply(); - emitEvent(EVENT_STEP_COMPLETED, null, 1.0f, STATE_INT_DONE); - handler.removeCallbacks(closeUssdAfterResultRunnable); - handler.postDelayed(closeUssdAfterResultRunnable, RESULT_THEN_CLOSE_DELAY_MS); - } else { - SharedPreferences prefs_local = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - int retry = prefs_local.getInt(KEY_CONFIRM_RETRY, 0); - if (retry < CONFIRM_MAX_RETRIES) { - prefs_local.edit().putInt(KEY_CONFIRM_RETRY, retry + 1).apply(); - handler.postDelayed(stepRunnable, STEP_DELAY_MS); - } - } - break; - } - - // ── Post-PIN confirmation menu: cancel (user opted to) ── - case STATE_CONFIRM_CANCEL: { - if (isPinDialogStillOpen(root)) { - handler.postDelayed(stepRunnable, STEP_DELAY_MS); - return; - } - // Honor the 5s wait even when event-driven re-posts fire - // before the deadline: only cancel once the delay has elapsed. - long cancelAt = prefs.getLong(KEY_CANCEL_AT, 0L); - long remaining = cancelAt - System.currentTimeMillis(); - if (remaining > 0) { - handler.postDelayed(stepRunnable, remaining + 50L); - return; - } - if (selectMenuOption(root, "2", "Cancel", "No")) { - SharedPreferences prefs_local = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - prefs_local.edit().putString(KEY_STATE, STATE_DONE).apply(); - emitEvent(EVENT_STEP_COMPLETED, null, 1.0f, STATE_INT_DONE); - handler.removeCallbacks(closeUssdAfterResultRunnable); - handler.postDelayed(closeUssdAfterResultRunnable, RESULT_THEN_CLOSE_DELAY_MS); - } else { - SharedPreferences prefs_local = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - int retry = prefs_local.getInt(KEY_CONFIRM_RETRY, 0); - if (retry < CONFIRM_MAX_RETRIES) { - prefs_local.edit().putInt(KEY_CONFIRM_RETRY, retry + 1).apply(); - handler.postDelayed(stepRunnable, STEP_DELAY_MS); - } - } - break; - } - - default: - fail("unknown_state: " + state); - } - } - - private boolean isPinDialogStillOpen(AccessibilityNodeInfo root) { - return rootContainsAnyText(root, java.util.Arrays.asList( - "Enter M-PESA PIN", "Enter M-Pesa PIN", "Enter PIN")); - } - - // Selects a numbered menu option. First tries clicking an existing node - // whose text/description is the digit or "N. …"/"N …" (menu options), then - // falls back to clicking any node labelled with one of the friendly words. - private boolean selectMenuOption(AccessibilityNodeInfo root, String digit, String... friendlyWords) { - if (clickNodeWithText(root, digit)) return true; - for (String word : friendlyWords) { - if (clickNodeWithText(root, word)) return true; - } - return false; - } - - private void typeAndSend(SharedPreferences prefs, AccessibilityNodeInfo root, String digit, String nextState, float progress) { - if (!typeInInputAndSend(root, digit)) { - handler.postDelayed(stepRunnable, STEP_DELAY_MS); - return; - } - prefs.edit().putString(KEY_STATE, nextState).apply(); - emitEvent(EVENT_STEP_COMPLETED, null, progress, STATE_INT_IN_PROGRESS); - handler.postDelayed(stepRunnable, STEP_DELAY_MS); - } - - private void typeField(SharedPreferences prefs, AccessibilityNodeInfo root, String value, String nextState, float progress, String... hintKeywords) { - if (TextUtils.isEmpty(value)) { fail("missing field"); return; } - boolean ok = setTextOnFocusedOrFirstEditable(root, value) - || setTextOnNodeWithHint(root, value, hintKeywords); - if (!ok) { handler.postDelayed(stepRunnable, STEP_DELAY_MS); return; } - clickSendOrOk(root); - prefs.edit().putString(KEY_STATE, nextState).apply(); - emitEvent(EVENT_STEP_COMPLETED, null, progress, STATE_INT_IN_PROGRESS); - handler.postDelayed(stepRunnable, STEP_DELAY_MS); - } - - private void finishPin(SharedPreferences prefs, AccessibilityNodeInfo root) { - String pin = prefs.getString(KEY_PIN, ""); - if (TextUtils.isEmpty(pin)) { - fail("pin_not_set"); - return; - } - // Prefer the field whose hint/description contains "pin", "mpesa", or - // "enter" so we never accidentally type the PIN into a confirmation - // input or the wrong editable field. - boolean pinSet = setTextOnNodeWithHint(root, pin, "pin", "mpesa", "enter") - || setTextOnFocusedOrFirstEditable(root, pin); - if (!pinSet) { - handler.postDelayed(stepRunnable, STEP_DELAY_MS); - return; - } - clickSendOrOk(root); - - boolean confirmSend = prefs.getBoolean(KEY_CONFIRM, false); - if (confirmSend) { - // Confirm checkbox ON: wait CONFIRM_CANCEL_DELAY_MS, then choose - // "2"/"Cancel" on the post-PIN confirmation menu. - long cancelAt = System.currentTimeMillis() + CONFIRM_CANCEL_DELAY_MS; - prefs.edit() - .putString(KEY_STATE, STATE_CONFIRM_CANCEL) - .putLong(KEY_CANCEL_AT, cancelAt) - .putInt(KEY_CONFIRM_RETRY, 0) - .apply(); - handler.postDelayed(stepRunnable, CONFIRM_CANCEL_DELAY_MS); - } else { - // Confirm checkbox OFF: proceed immediately on the post-PIN - // confirmation menu (choose "1"/"Send"). - prefs.edit() - .putString(KEY_STATE, STATE_CONFIRM_1) - .putInt(KEY_CONFIRM_RETRY, 0) - .apply(); - handler.postDelayed(stepRunnable, CONFIRM_DELAY_MS); - } - } - - private void closeUssdAfterResult() { - AccessibilityNodeInfo root = getUssdDialogRoot(); - if (root != null) { - // 1. Collect all text recursively (root.getText() returns null on ViewGroup roots). - String allText = collectAllText(root); - if (!TextUtils.isEmpty(allText)) { - emitEvent(EVENT_RESULT_TEXT, allText, 1.0f, STATE_INT_DONE); - } - // 2. Try clicking OK / Send / Confirm to dismiss the result dialog. - clickSendOrOk(root); - } - SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - prefs.edit() - .putBoolean(KEY_PENDING, false) - .putString(KEY_STATE, STATE_DONE) - .remove(KEY_PIN) // SECURITY: scrub PIN on completion - .apply(); - emitEvent(EVENT_SESSION_ENDED, null, 1.0f, STATE_INT_DONE); - performGlobalAction(GLOBAL_ACTION_BACK); - handler.removeCallbacks(timeoutRunnable); - sessionStartMs = 0L; - } - - private void fail(String reason) { - SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - prefs.edit() - .putBoolean(KEY_PENDING, false) - .putString(KEY_STATE, STATE_DONE) - .remove(KEY_PIN) // SECURITY: scrub PIN on failure - .apply(); - emitEvent(EVENT_ERROR, reason, 0f, STATE_INT_FAILED); - emitEvent(EVENT_SESSION_ENDED, null, 0f, STATE_INT_FAILED); - performGlobalAction(GLOBAL_ACTION_BACK); - handler.removeCallbacks(timeoutRunnable); - sessionStartMs = 0L; - } - - private void emitEvent(int type, String text, float progress, int state) { - try { - rustOnUssdEvent(type, text, progress, state); - } catch (UnsatisfiedLinkError ignored) { /* native not yet registered */ } - } - - // ─── Accessibility tree helpers (ported from PesaMirror) ────────────── - - /** Recursively collect all text from a node and its children. */ - private String collectAllText(AccessibilityNodeInfo node) { - if (node == null) return ""; - StringBuilder sb = new StringBuilder(); - CharSequence t = node.getText(); - if (t != null) sb.append(t); - for (int i = 0; i < node.getChildCount(); i++) { - AccessibilityNodeInfo c = node.getChild(i); - if (c != null) sb.append(collectAllText(c)); - } - return sb.toString().trim(); - } - - private AccessibilityNodeInfo getUssdDialogRoot() { - AccessibilityNodeInfo active = getRootInActiveWindow(); - if (active != null && !isOurAppRoot(active) && rootContainsAnyText(active, USSD_MARKERS)) { - return active; - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - List windowList = getWindows(); - if (windowList == null) return null; - AccessibilityNodeInfo lastMatch = null; - for (AccessibilityWindowInfo w : windowList) { - if (w == null) continue; - AccessibilityNodeInfo root = w.getRoot(); - if (root == null || isOurAppRoot(root)) continue; - if (rootContainsAnyText(root, USSD_MARKERS)) { - lastMatch = root; - } - } - return lastMatch; - } - return null; - } - - private boolean isOurAppRoot(AccessibilityNodeInfo root) { - if (root == null) return false; - CharSequence pkg = root.getPackageName(); - if (pkg == null) return false; - String p = pkg.toString(); - return getPackageName().equals(p); - } - - private boolean rootContainsAnyText(AccessibilityNodeInfo root, List markers) { - if (root == null) return false; - CharSequence text = root.getText(); - if (text != null) { - String t = text.toString().toLowerCase(); - for (String m : markers) if (t.contains(m.toLowerCase())) return true; - } - CharSequence desc = root.getContentDescription(); - if (desc != null) { - String d = desc.toString().toLowerCase(); - for (String m : markers) if (d.contains(m.toLowerCase())) return true; - } - for (int i = 0; i < root.getChildCount(); i++) { - AccessibilityNodeInfo c = root.getChild(i); - if (c != null && rootContainsAnyText(c, markers)) return true; - } - return false; - } - - // ── Text input helpers (matching Kotlin PesaMirror) ───────────────────────── - - private boolean setTextOnFocusedOrFirstEditable(AccessibilityNodeInfo root, String text) { - AccessibilityNodeInfo target = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT); - if (target == null) { - java.util.List editables = new java.util.ArrayList<>(); - collectEditableNodes(root, editables); - target = editables.isEmpty() ? null : editables.get(0); - } - if (target == null) target = findFirstFocusableInput(root); - if (target == null) target = findFirstFocusableInRoot(root); - return target != null && setTextOnNode(target, text); - } - - private AccessibilityNodeInfo findFirstFocusableInput(AccessibilityNodeInfo node) { - String className = node.getClassName() != null ? node.getClassName().toString() : ""; - if (node.isFocusable() && (node.isEditable() || className.contains("EditText"))) { - return node; - } - for (int i = 0; i < node.getChildCount(); i++) { - AccessibilityNodeInfo c = node.getChild(i); - if (c != null) { - AccessibilityNodeInfo found = findFirstFocusableInput(c); - if (found != null) return found; - } - } - return null; - } - - private AccessibilityNodeInfo findFirstFocusableInRoot(AccessibilityNodeInfo node) { - if (node.isFocusable() && node.getClassName() != null - && node.getClassName().toString().contains("Edit")) { - return node; - } - for (int i = 0; i < node.getChildCount(); i++) { - AccessibilityNodeInfo c = node.getChild(i); - if (c != null) { - AccessibilityNodeInfo found = findFirstFocusableInRoot(c); - if (found != null) return found; - } - } - return null; - } - - private void collectEditableNodes(AccessibilityNodeInfo node, java.util.List out) { - if (node.isEditable()) out.add(node); - for (int i = 0; i < node.getChildCount(); i++) { - AccessibilityNodeInfo c = node.getChild(i); - if (c != null) collectEditableNodes(c, out); - } - } - - private boolean setTextOnNodeWithHint(AccessibilityNodeInfo root, String text, String... hintKeywords) { - if (root == null) return false; - CharSequence hint = root.getHintText(); - if (hint != null) { - String h = hint.toString().toLowerCase(); - for (String k : hintKeywords) if (h.contains(k.toLowerCase())) return setTextOnNode(root, text); - } - CharSequence desc = root.getContentDescription(); - if (desc != null) { - String d = desc.toString().toLowerCase(); - for (String k : hintKeywords) if (d.contains(k.toLowerCase())) return setTextOnNode(root, text); - } - for (int i = 0; i < root.getChildCount(); i++) { - AccessibilityNodeInfo c = root.getChild(i); - if (c != null && setTextOnNodeWithHint(c, text, hintKeywords)) return true; - } - return false; - } - - private boolean setTextOnNode(AccessibilityNodeInfo node, String text) { - if (node == null) return false; - Bundle args = new Bundle(); - args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text); - return node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args); - } - - private boolean typeInInputAndSend(AccessibilityNodeInfo root, String digit) { - if (setTextOnFocusedOrFirstEditable(root, digit)) { - clickSendOrOk(root); - return true; - } - return false; - } - - private void clickSendOrOk(AccessibilityNodeInfo root) { - String[] labels = {"Send", "OK", "Submit", "Confirm", "SEND", "Ok"}; - for (String label : labels) if (clickNodeWithText(root, label)) return; - } - - private boolean clickNodeWithText(AccessibilityNodeInfo root, String text) { - java.util.List nodes = new java.util.ArrayList<>(); - collectNodesWithText(root, text, nodes); - for (AccessibilityNodeInfo node : nodes) { - if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) return true; - AccessibilityNodeInfo parent = node.getParent(); - while (parent != null) { - if (parent.performAction(AccessibilityNodeInfo.ACTION_CLICK)) return true; - parent = parent.getParent(); - } - } - return false; - } - - private void collectNodesWithText(AccessibilityNodeInfo node, String text, java.util.List out) { - if (node == null) return; - String nodeText = node.getText() != null ? node.getText().toString().trim() : ""; - String desc = node.getContentDescription() != null ? node.getContentDescription().toString().trim() : ""; - boolean match; - if (text.length() == 1 && Character.isDigit(text.charAt(0))) { - // A single digit matches an exact "N", a "N. …" / "N …" menu option, - // or a node whose description carries the same digit prefix. - match = nodeText.equals(text) || desc.equals(text) - || nodeText.startsWith(text + ".") || nodeText.startsWith(text + " ") - || desc.startsWith(text + ".") || desc.startsWith(text + " "); - } else { - match = text.equalsIgnoreCase(nodeText) || text.equalsIgnoreCase(desc); - } - if (match) out.add(node); - for (int i = 0; i < node.getChildCount(); i++) { - AccessibilityNodeInfo c = node.getChild(i); - if (c != null) collectNodesWithText(c, text, out); - } - } - -} diff --git a/crates/pageflipnav/src/home/home_screen.rs b/crates/pageflipnav/src/home/home_screen.rs index 69aa6c7..3447d6c 100644 --- a/crates/pageflipnav/src/home/home_screen.rs +++ b/crates/pageflipnav/src/home/home_screen.rs @@ -1,5 +1,4 @@ -use std::time::Instant; use makepad_widgets::*; use nigig_core::settings::settings_screen::SettingsAction; use nigig_build::project_store; @@ -614,7 +613,6 @@ pub struct HomeScreen { impl Widget for HomeScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - let t0 = Instant::now(); // Force-hide spaces bar when app goes to background, preventing any // stale animator state from showing the panel on resume / surface // recreation (Android 15 wake-from-screen-off). @@ -698,7 +696,6 @@ impl Widget for HomeScreen { self.view.redraw(cx); } Some(NavigationBarAction::EnterWorkSection) => { - let t0 = Instant::now(); self.active_standalone_page = None; self.active_home_stack_view = None; self.previous_selection = app_state.selected_tab.clone(); @@ -707,13 +704,9 @@ impl Widget for HomeScreen { cx.action(NavigationBarAction::TabSelected( app_state.selected_tab.clone(), )); - log!("[TIMING] EnterWorkSection: pre-switch {:?}", t0.elapsed()); self.switch_navigation_bar(cx, id!(work_nav)); - log!("[TIMING] EnterWorkSection: post-switch {:?}", t0.elapsed()); self.update_active_page_from_selection(cx, app_state); - log!("[TIMING] EnterWorkSection: post-update {:?}", t0.elapsed()); self.view.redraw(cx); - log!("[TIMING] EnterWorkSection: post-redraw {:?}", t0.elapsed()); } Some(NavigationBarAction::ReturnToHome) => { self.active_standalone_page = None; @@ -938,7 +931,6 @@ impl Widget for HomeScreen { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - let t0 = Instant::now(); self.sync_projects(cx); let app_state = scope.data.get_mut::().unwrap(); let rect = cx.peek_walk_turtle(walk); @@ -950,10 +942,7 @@ impl Widget for HomeScreen { } else { self.update_active_page_from_selection(cx, app_state); } - log!("[TIMING] HomeScreen::draw_walk: pre-draw {:?}", t0.elapsed()); - let r = self.view.draw_walk(cx, scope, walk); - log!("[TIMING] HomeScreen::draw_walk: total {:?}", t0.elapsed()); - r + self.view.draw_walk(cx, scope, walk) } } diff --git a/crates/pageflipnav/src/work/work_navigation_bar.rs b/crates/pageflipnav/src/work/work_navigation_bar.rs index 43b8d6f..4b129a7 100644 --- a/crates/pageflipnav/src/work/work_navigation_bar.rs +++ b/crates/pageflipnav/src/work/work_navigation_bar.rs @@ -389,7 +389,7 @@ script_mod! { } mod.widgets.SectionSeparator {} - work_desktop_action_bar_flip := PageFlip { width: Fill, height: Fill, lazy_init: true, active_page: @pay_action_page + work_desktop_action_bar_flip := PageFlip { width: Fill, height: Fill, active_page: @pay_action_page pay_action_page := View { width: Fill, height: Fill mod.widgets.PayActionBarVertical {} } insure_action_page := View { width: Fill, height: Fill mod.widgets.InsureActionBarVertical {} } build_action_page := View { width: Fill, height: Fill mod.widgets.BuildActionBarVertical {} } @@ -458,7 +458,6 @@ script_mod! { work_content_page_flip := PageFlip { width: Fill, height: Fit - lazy_init: true active_page: @work_content_page work_content_page := View { @@ -487,7 +486,7 @@ script_mod! { } footer +: { - work_action_bar_flip := PageFlip { width: Fill, height: Fit, lazy_init: true, active_page: @pay_action_page + work_action_bar_flip := PageFlip { width: Fill, height: Fit, active_page: @pay_action_page pay_action_page := View { width: Fill, height: Fit mod.widgets.PayActionBar {} } insure_action_page := View { width: Fill, height: Fit mod.widgets.InsureActionBar {} } build_action_page := View { width: Fill, height: Fit mod.widgets.BuildActionBar {} } @@ -521,13 +520,7 @@ impl ScriptHook for WorkNavigationBar { self.is_nav_expanded = false; self.mobile_available_height = SECTION_NAV_DEFAULT_FULL_HEIGHT; self.view.pull_up_sheet(cx, ids!(Mobile)).set_available_height(cx, self.mobile_available_height); - // In test mode keep the mobile pull-up sheet expanded so - // test automation can reach the grid buttons without touch-dragging. - if std::env::var("NIGIG_TEST_MODE").is_ok() { - self.view.pull_up_sheet(cx, ids!(Mobile)).expand_full(cx); - } else { - self.view.pull_up_sheet(cx, ids!(Mobile)).collapse(cx); - } + self.view.pull_up_sheet(cx, ids!(Mobile)).collapse(cx); self.last_clicked_action = None; self.update_nav_labels(cx); self.update_action_bar(cx, &WorkTab::Payments); diff --git a/crates/pageflipnav/src/work/work_screen.rs b/crates/pageflipnav/src/work/work_screen.rs index 65fa2cf..c321c0e 100644 --- a/crates/pageflipnav/src/work/work_screen.rs +++ b/crates/pageflipnav/src/work/work_screen.rs @@ -1,7 +1,6 @@ use crate::{work::work_navigation_bar::WorkNavigationAction, AppState, WorkTab}; use makepad_widgets::*; -use std::time::Instant; script_mod! { use mod.prelude.widgets.* @@ -13,7 +12,6 @@ script_mod! { work_page_flip := PageFlip { width: Fill, height: Fill - lazy_init: true active_page: @payments_page payments_page := View { @@ -96,13 +94,9 @@ impl Widget for WorkScreen { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - let t0 = Instant::now(); let app_state = scope.data.get_mut::().unwrap(); self.update_active_page(cx, &app_state.selected_work_tab); - log!("[TIMING] WorkScreen::draw_walk: post-update_active_page {:?}", t0.elapsed()); - let r = self.view.draw_walk(cx, scope, walk); - log!("[TIMING] WorkScreen::draw_walk: total {:?}", t0.elapsed()); - r + self.view.draw_walk(cx, scope, walk) } } diff --git a/crates/pageflipnav/tests/ui.rs b/crates/pageflipnav/tests/ui.rs index 83c5e07..65c3532 100644 --- a/crates/pageflipnav/tests/ui.rs +++ b/crates/pageflipnav/tests/ui.rs @@ -1,4 +1,4 @@ -use makepad_test::{makepad_test, Selector, TestApp, WidgetSnapshot}; +use makepad_widgets::makepad_test::{makepad_test, Selector, TestApp}; /// Skip test when NIGIG_TEST_MODE is not set (used for tests that need /// the home screen to be visible, bypassing login). @@ -6,63 +6,8 @@ fn require_test_mode() -> bool { std::env::var("NIGIG_TEST_MODE").is_ok() } -/// Navigate from home → work → construction_grid → m_workspace_docs_btn -/// → (dashboard) new_document_btn → crdt_editor (the CRDT doc workspace). -fn navigate_to_doc_workspace(app: &TestApp) { - if !require_test_mode() { - return; - } - app.locator(Selector::id("work_button")) - .wait_visible() - .click(); - // The work screen opens with its bottom pull-up sheet collapsed, hiding - // the section grid buttons. Drag the sheet's pull handle upward to expand - // it so the additional nav buttons become visible. - app.locator(Selector::id("pull_handle")) - .wait_visible() - .drag_by(0.0, -600.0); - // The construction grid button opens nigig-build's BuildProjectsPage - // (mobile variant), whose Workspace list is the entry to the docs. - app.locator(Selector::id("construction_grid_button")) - .wait_visible() - .click(); - app.locator(Selector::id("m_workspace_docs_btn")) - .wait_visible() - .click(); - // The CRDT doc workspace opens on its dashboard; "New Document" flips it - // to the editor (the saved-doc cards are hit-tested rects, not widgets). - app.locator(Selector::id("new_document_btn")) - .wait_visible() - .click(); - app.locator(Selector::id("crdt_editor")).wait_visible(); -} - -/// Poll the editor's snapshot until its height grows past `before.height` -/// (the insert_table grid needs a rendered frame to show up; a single -/// snapshot right after the button click can race the layout refresh). -/// Returns the grown snapshot, panicking if it never appears. -fn wait_editor_grows(app: &TestApp, before: &WidgetSnapshot) -> WidgetSnapshot { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - loop { - let snap = app.locator(Selector::id("crdt_editor")).snapshot(); - if snap.height > before.height { - return snap; - } - if std::time::Instant::now() >= deadline { - panic!( - "editor never grew past {} (stayed at {}): insert_table produced no visible grid", - before.height, snap.height - ); - } - std::thread::sleep(std::time::Duration::from_millis(150)); - } -} - #[makepad_test] fn app_launches_and_shows_login_screen(app: TestApp) { - if require_test_mode() { - return; - } app.locator(Selector::id("login_button")) .wait_visible(); app.locator(Selector::id("user_id_input")) @@ -79,9 +24,6 @@ fn app_launches_and_shows_login_screen(app: TestApp) { #[makepad_test] fn login_form_accepts_input(app: TestApp) { - if require_test_mode() { - return; - } app.locator(Selector::id("user_id_input")) .wait_visible() .fill("@testuser:matrix.org") @@ -98,23 +40,16 @@ fn login_form_accepts_input(app: TestApp) { #[makepad_test] fn show_password_toggle_works(app: TestApp) { - if require_test_mode() { - return; - } - let _pw = app.locator(Selector::id("password_input")).wait_visible(); - app.locator(Selector::id("show_password_button")) - .wait_visible() - .click(); + let pw = app.locator(Selector::id("password_input")).wait_visible(); + let show_btn = app.locator(Selector::id("show_password_button")).wait_visible(); + show_btn.click(); let hide_btn = app.locator(Selector::id("hide_password_button")).wait_visible(); hide_btn.click(); - app.locator(Selector::id("show_password_button")).wait_visible(); + show_btn.wait_visible(); } #[makepad_test] fn login_with_empty_fields_shows_modal(app: TestApp) { - if require_test_mode() { - return; - } app.locator(Selector::id("login_button")) .wait_visible() .click(); @@ -128,9 +63,6 @@ fn login_with_empty_fields_shows_modal(app: TestApp) { #[makepad_test] fn login_with_password_only_shows_missing_user_id(app: TestApp) { - if require_test_mode() { - return; - } app.locator(Selector::id("password_input")) .wait_visible() .fill("some_password"); @@ -143,9 +75,6 @@ fn login_with_password_only_shows_missing_user_id(app: TestApp) { #[makepad_test] fn login_with_user_id_only_shows_missing_password(app: TestApp) { - if require_test_mode() { - return; - } app.locator(Selector::id("user_id_input")) .wait_visible() .fill("@test:matrix.org"); @@ -158,21 +87,15 @@ fn login_with_user_id_only_shows_missing_password(app: TestApp) { #[makepad_test] fn login_status_modal_can_be_closed(app: TestApp) { - if require_test_mode() { - return; - } app.locator(Selector::id("login_button")).wait_visible().click(); let modal = app.locator(Selector::id("login_status_modal_inner")).wait_visible(); let close_btn = app.locator(Selector::widget_type("Button").text_exact("Okay")).wait_visible(); close_btn.click(); - modal.try_wait_hidden().unwrap(); + modal.wait_not_visible(); } #[makepad_test] fn signup_button_fires_correctly(app: TestApp) { - if require_test_mode() { - return; - } app.locator(Selector::id("signup_button")) .wait_visible() .click(); @@ -180,9 +103,6 @@ fn signup_button_fires_correctly(app: TestApp) { #[makepad_test] fn sso_buttons_are_present(app: TestApp) { - if require_test_mode() { - return; - } let sso_providers = [ "apple_button", "facebook_button", @@ -192,7 +112,7 @@ fn sso_buttons_are_present(app: TestApp) { "twitter_button", ]; for provider_id in &sso_providers { - app.locator(Selector::id(*provider_id)).wait_visible(); + app.locator(Selector::id(provider_id)).wait_visible(); } } @@ -286,469 +206,3 @@ fn home_sub_tab_profile_click(app: TestApp) { .click(); app.locator(Selector::id("home_profile_tab_btn")).wait_visible(); } - -// ── Device verification: doc workspace sections 1–5 ── -// Execute against the CRDT workspace (CrdtDocWorkspace). -// Full runbook: DEVICE_VERIFICATION.md (extracted from git history). - -/// Section 1.1–1.4: Interaction mode View ↔ Edit. -/// -/// Cold-launch in View mode; the Edit button switches to Edit mode; -/// Done switches back. Touch in View is passive (no keyboard). -#[makepad_test] -fn doc_interaction_mode_view_and_edit(app: TestApp) { - if !require_test_mode() { - return; - } - navigate_to_doc_workspace(&app); - - // 1.1: Cold-launch — first tap should NOT open keyboard (View mode). - // We verify that Edit button is visible (the AdaptiveView Mobile variant). - app.locator(Selector::id("edit_mode_btn")).wait_visible(); - - // 1.3: Tap Edit → toolbar switches to Done. - app.locator(Selector::id("edit_mode_btn")) - .wait_visible() - .click(); - app.locator(Selector::id("edit_mode_btn")) - .wait_visible() - .assert_text("Done"); - - // 1.4: Tap Done → back to Edit label. - app.locator(Selector::id("edit_mode_btn")) - .wait_visible() - .click(); - app.locator(Selector::id("edit_mode_btn")) - .wait_visible() - .assert_text("Edit"); -} - -/// Section 1.2: View mode scroll — touch-move inside the document area -/// should scroll, not select text. -#[makepad_test] -fn doc_view_mode_scroll_by_touch(app: TestApp) { - if !require_test_mode() { - return; - } - navigate_to_doc_workspace(&app); - - // In View mode, touch-drag should scroll the page. - let editor = app.locator(Selector::id("crdt_editor")).wait_visible(); - let snap = editor.snapshot(); - - // Touch down at center, move down (scroll up), release. - let (cx, cy) = (snap.x as f64 + snap.width as f64 / 2.0, snap.y as f64 + snap.height as f64 / 2.0); - app.touch_down(cx, cy); - app.touch_move(cx, cy - 80.0); - app.touch_up(cx, cy - 80.0); - - // Editor should still be visible (page scrolled, not crashed). - app.locator(Selector::id("crdt_editor")).wait_visible(); -} - -/// Section 2.1: IME text input in Edit mode. -/// -/// Enter Edit mode, type text, verify the widget receives it. -#[makepad_test] -fn doc_ime_text_input_in_edit_mode(app: TestApp) { - if !require_test_mode() { - return; - } - navigate_to_doc_workspace(&app); - - // Switch to Edit mode. - app.locator(Selector::id("edit_mode_btn")) - .wait_visible() - .click(); - app.locator(Selector::id("edit_mode_btn")) - .wait_visible() - .assert_text("Done"); - - // Tap on the editor to focus, then type. - let editor = app.locator(Selector::id("crdt_editor")).wait_visible(); - editor.click(); - app.type_text("Hello from device verification"); - - // Status bar should still show the engine is ready. - app.locator(Selector::id("status_left")).wait_visible(); -} - -/// Section 2.2: IME composition in a table cell. -/// -/// Tap inside the document (which may contain a table), verify IME -/// composition text can be sent. -#[makepad_test] -fn doc_ime_composition_sends_text(app: TestApp) { - if !require_test_mode() { - return; - } - navigate_to_doc_workspace(&app); - - app.locator(Selector::id("edit_mode_btn")) - .wait_visible() - .click(); - - let editor = app.locator(Selector::id("crdt_editor")).wait_visible(); - editor.click(); - - // Send an IME composition — this simulates composing text before commit. - app.ime_composition("composed text"); - - // The document should still be alive. - app.locator(Selector::id("crdt_editor")).wait_visible(); -} - -/// Section 5.1–5.2: Scroll handoff to parent ScrollYView. -/// -/// In View mode, a vertical drag should scroll; in Edit mode, a quick -/// drag should also scroll (before long-press arms). -#[makepad_test] -fn doc_scroll_handoff_view_and_edit(app: TestApp) { - if !require_test_mode() { - return; - } - navigate_to_doc_workspace(&app); - - // 5.1: View mode — drag should scroll. - let editor = app.locator(Selector::id("crdt_editor")).wait_visible(); - let snap = editor.snapshot(); - let (cx, cy) = (snap.x as f64 + snap.width as f64 / 2.0, snap.y as f64 + snap.height as f64 / 2.0); - - app.touch_down(cx, cy); - // Quick drag (short distance, well under the long-press threshold). - app.touch_move(cx, cy - 20.0); - app.touch_up(cx, cy - 20.0); - // Editor still visible — page scrolled, not selected. - app.locator(Selector::id("crdt_editor")).wait_visible(); - - // 5.2: Switch to Edit mode — quick drag should still scroll. - app.locator(Selector::id("edit_mode_btn")) - .wait_visible() - .click(); - - let editor = app.locator(Selector::id("crdt_editor")).wait_visible(); - let snap = editor.snapshot(); - let (cx, cy) = (snap.x as f64 + snap.width as f64 / 2.0, snap.y as f64 + snap.height as f64 / 2.0); - - app.touch_down(cx, cy); - app.touch_move(cx, cy - 20.0); - app.touch_up(cx, cy - 20.0); - app.locator(Selector::id("crdt_editor")).wait_visible(); -} - -/// Section 3.1: Long-press triggers selection. -/// -/// In Edit mode, a long-press on text should arm word selection. -/// We verify the mechanism by sending a long-press event and confirming -/// the document is still responsive. -#[makepad_test] -fn doc_long_press_arms_selection(app: TestApp) { - if !require_test_mode() { - return; - } - navigate_to_doc_workspace(&app); - - app.locator(Selector::id("edit_mode_btn")) - .wait_visible() - .click(); - - let editor = app.locator(Selector::id("crdt_editor")).wait_visible(); - let snap = editor.snapshot(); - let (cx, cy) = (snap.x as f64 + snap.width as f64 / 2.0, snap.y as f64 + snap.height as f64 / 2.0); - - // Long-press at center for 500ms (the default threshold). - app.long_press(cx, cy, 500.0); - - // Document should still be alive after the long-press. - app.locator(Selector::id("crdt_editor")).wait_visible(); -} - -#[makepad_test] -fn doc_debug_nav_walk(app: TestApp) { - if !require_test_mode() { - return; - } - app.locator(Selector::id("work_button")) - .wait_visible() - .click(); - app.locator(Selector::id("pull_handle")) - .wait_visible() - .drag_by(0.0, -600.0); - app.locator(Selector::id("construction_grid_button")) - .wait_visible() - .click(); - app.locator(Selector::id("m_workspace_docs_btn")) - .wait_visible() - .click(); - app.locator(Selector::id("new_document_btn")) - .wait_visible() - .click(); - app.locator(Selector::id("crdt_editor")).wait_visible(); -} - -/// Section 9.0: Boot content — the CRDT workspace renders the editor after -/// navigating to a new blank document; the status bar reflects the blank -/// state without switching to the loaded-document engine string. -#[makepad_test] -fn doc_boot_content_renders(app: TestApp) { - if !require_test_mode() { - return; - } - navigate_to_doc_workspace(&app); - // A freshly created blank document reports its status here before the - // engine switches to a loaded-document status string. - app.locator(Selector::id("status_left")) - .wait_visible() - .assert_text("New blank document"); - - // The CRDT editor is visible (document rendered). - app.locator(Selector::id("crdt_editor")).wait_visible(); -} - -/// Section 10.1: Typing actually inserts document content. After switching -/// to Edit and focusing the editor, IME text lands in the document: the -/// stats bar (refreshed by a toolbar click) reports the typed words/chars. -#[makepad_test] -fn doc_type_text_inserts_document(app: TestApp) { - if !require_test_mode() { - return; - } - navigate_to_doc_workspace(&app); - let snap_of = |id: &str| app.locator(Selector::id(id)).snapshot(); - - app.locator(Selector::id("edit_mode_btn")).wait_visible().click(); - let editor = app.locator(Selector::id("crdt_editor")).wait_visible(); - editor.click(); - app.type_text("hello world"); - - // A toolbar click drives handle_actions, which refreshes stats_label - // from the projected document (typing alone doesn't refresh it). - app.locator(Selector::id("bold_btn")).click(); - app.locator(Selector::id("stats_label")) - .wait_visible() - .assert_text("Page 1 of 1 | 2 words | 11 chars"); - assert_eq!(snap_of("stats_label").text, Some("Page 1 of 1 | 2 words | 11 chars".into())); -} - -/// Section 10.2: The bold/italic/underline toolbar buttons drive real -/// document styling through the editor without corrupting content. Each -/// toggle keeps the typed text intact (word/char stats unchanged after a -/// refresh), and toggling a style back off leaves the doc stable. Actual -/// style semantics (run spans on the selection) are asserted by the -/// doc-ui runtime unit suite (e.g. runtime_ctrl_b_applies_bold...). -#[makepad_test] -fn doc_bold_italic_underline_ops(app: TestApp) { - if !require_test_mode() { - return; - } - navigate_to_doc_workspace(&app); - let snap_of = |id: &str| app.locator(Selector::id(id)).snapshot(); - - app.locator(Selector::id("edit_mode_btn")).wait_visible().click(); - let editor = app.locator(Selector::id("crdt_editor")).wait_visible(); - editor.click(); - app.type_text("hello world"); - - for id in ["bold_btn", "italic_btn", "underline_btn"] { - app.locator(Selector::id(id)).wait_visible().click(); - app.locator(Selector::id(id)).wait_visible().click(); - } - - // Refresh stats; styling must not alter the text. - app.locator(Selector::id("bold_btn")).click(); - assert_eq!(snap_of("stats_label").text, Some("Page 1 of 1 | 2 words | 11 chars".into())); - app.locator(Selector::id("crdt_editor")).wait_visible(); -} - -/// Section 10.3: Inserting a table produces a visible, usable grid and text -/// typed into a cell reaches the cell. The editor's height grows when the -/// seeded 2x2 grid appears (a bare table block renders at zero size), and -/// after a short tap on a visible cell + IME text the stats bar (which -/// counts table-cell words, refreshed by a toolbar click) reports the cell -/// content alongside the block text. -#[makepad_test] -fn doc_insert_table_and_cell_text(app: TestApp) { - if !require_test_mode() { - return; - } - navigate_to_doc_workspace(&app); - let snap_of = |id: &str| app.locator(Selector::id(id)).snapshot(); - - app.locator(Selector::id("edit_mode_btn")).wait_visible().click(); - let editor = app.locator(Selector::id("crdt_editor")).wait_visible(); - editor.click(); - app.type_text("hello world"); - app.locator(Selector::id("bold_btn")).click(); - - let before = app.locator(Selector::id("crdt_editor")).snapshot(); - app.locator(Selector::id("insert_table_btn")).click(); - let after = wait_editor_grows(&app, &before); - // column (col1) is tappable. A short tap focuses the cell and shows the - // IME (Edit mode), so the subsequent type_text lands in the cell. - let raw_left = after.x as f64 + (after.width as f64 - 612.0) / 2.0; - let table_top = after.y as f64 + before.height as f64; - let col0_mid = raw_left + 80.0; - let row0 = table_top + 14.0; - let col1_mid = raw_left + 240.0; - app.touch_down(col1_mid, row0); - app.touch_up(col1_mid, row0); - app.type_text("alpha beta"); - app.locator(Selector::id("bold_btn")).click(); - - // block "hello world" (2 words / 11 chars) + cell "alpha beta" - // (2 words / 10 chars) = 4 words / 21 chars. - assert_eq!( - snap_of("stats_label").text, - Some("Page 1 of 1 | 4 words | 21 chars".into()), - "cell text must join the document stats: {:?}", - snap_of("stats_label").text - ); - let _ = col0_mid; -} - -/// Section 10.4: Merge and split actually operate on the table's cells. A -/// touch-hold-then-drag on the visible right column arms a cell range -/// (Shift+Arrow on desktop); the merge toolbar button then merges the two -/// cells and reports "Merged selected cells", and split re-splits it and -/// reports "Split merged cell". -#[makepad_test] -fn doc_merge_split_cell_ops(app: TestApp) { - if !require_test_mode() { - return; - } - navigate_to_doc_workspace(&app); - let snap_of = |id: &str| app.locator(Selector::id(id)).snapshot(); - - app.locator(Selector::id("edit_mode_btn")).wait_visible().click(); - let editor = app.locator(Selector::id("crdt_editor")).wait_visible(); - editor.click(); - app.type_text("hello world"); - app.locator(Selector::id("bold_btn")).click(); - - let before = app.locator(Selector::id("crdt_editor")).snapshot(); - app.locator(Selector::id("insert_table_btn")).click(); - let after = wait_editor_grows(&app, &before); - - // Reachable right column (col1), rows 0-1 of the seeded 2x2 grid. - let raw_left = after.x as f64 + (after.width as f64 - 612.0) / 2.0; - let table_top = after.y as f64 + before.height as f64; - let col1 = raw_left + 240.0; - let row0 = table_top + 14.0; - let row1 = table_top + 42.0; - - // Touch-hold (long-press arm) then drag down one row to span the range. - app.touch_down(col1, row0); - std::thread::sleep(std::time::Duration::from_millis(700)); - app.touch_move(col1, row1); - app.touch_up(col1, row1); - - app.locator(Selector::id("merge_cells_btn")).click(); - assert_eq!( - snap_of("status_left").text, - Some("Merged selected cells".into()), - "merge feedback: {:?}", - snap_of("status_left").text - ); - - app.locator(Selector::id("split_cell_btn")).click(); - assert_eq!( - snap_of("status_left").text, - Some("Split merged cell".into()), - "split feedback: {:?}", - snap_of("status_left").text - ); -} - -/// Section 3.4: A long-press on empty space between content must NOT arm a -/// cell range or start a word selection — the document stays inert. Proven -/// by long-pressing well below the only content, then clicking Merge: with -/// no range armed it must report the "span a range" guidance, not merge. -#[makepad_test] -fn doc_long_press_empty_space_does_not_arm(app: TestApp) { - if !require_test_mode() { - return; - } - navigate_to_doc_workspace(&app); - let snap_of = |id: &str| app.locator(Selector::id(id)).snapshot(); - - app.locator(Selector::id("edit_mode_btn")).wait_visible().click(); - let editor = app.locator(Selector::id("crdt_editor")).wait_visible(); - editor.click(); - app.type_text("hello world"); - app.locator(Selector::id("bold_btn")).click(); - - // Long-press well below the text block on empty space. - let snap = app.locator(Selector::id("crdt_editor")).snapshot(); - let empty_y = snap.y as f64 + snap.height as f64 - 20.0; - let empty_x = snap.x as f64 + snap.width as f64 / 2.0; - app.long_press(empty_x, empty_y, 500.0); - - // Nothing armed: Merge must report the guidance, not a merge. - app.locator(Selector::id("merge_cells_btn")).click(); - assert_eq!( - snap_of("status_left").text, - Some("Span a cell range with Shift+Arrow first".into()), - "long-press on empty space must NOT arm a cell range: {:?}", - snap_of("status_left").text - ); -} - -/// Section 4.1: A touch-hold on the left column's visible sliver dragged -/// diagonally to the bottom-right cell spans a 2x2 cell range, which Merge -/// consumes (anchor keeps its content). Mirrors the proven 1x2 path but -/// crosses both axes. Coordinates are clamped to the on-screen editor so -/// they hold on either the 411 dp (A60) or 384 dp (A16) screen. -#[makepad_test] -fn doc_diagonal_cell_range_merges(app: TestApp) { - if !require_test_mode() { - return; - } - navigate_to_doc_workspace(&app); - let snap_of = |id: &str| app.locator(Selector::id(id)).snapshot(); - - app.locator(Selector::id("edit_mode_btn")).wait_visible().click(); - let editor = app.locator(Selector::id("crdt_editor")).wait_visible(); - editor.click(); - app.type_text("hello world"); - app.locator(Selector::id("bold_btn")).click(); - - let before = app.locator(Selector::id("crdt_editor")).snapshot(); - app.locator(Selector::id("insert_table_btn")).click(); - let after = wait_editor_grows(&app, &before); - - // On-screen geometry (clamped so col0 and col1 stay reachable). - let raw_left = after.x as f64 + (after.width as f64 - 612.0) / 2.0; - let table_top = after.y as f64 + before.height as f64; - let row0 = table_top + 14.0; - let row1 = table_top + 42.0; - // col0's center can sit off the left edge; tap a point just inside the - // editor's visible left region, which still falls in col0. - let col0_tap = if raw_left + 80.0 > after.x as f64 + 8.0 { - raw_left + 80.0 - } else { - after.x as f64 + 12.0 - }; - let col1 = (raw_left + 240.0).min(after.x as f64 + after.width as f64 - 20.0); - - // Touch-hold in (col0,row0), drag diagonally to (col1,row1): spans 2x2. - app.touch_down(col0_tap, row0); - std::thread::sleep(std::time::Duration::from_millis(700)); - app.touch_move(col1, row1); - app.touch_up(col1, row1); - - app.locator(Selector::id("merge_cells_btn")).click(); - assert_eq!( - snap_of("status_left").text, - Some("Merged selected cells".into()), - "diagonal 2x2 range must merge: {:?}", - snap_of("status_left").text - ); - - app.locator(Selector::id("split_cell_btn")).click(); - assert_eq!( - snap_of("status_left").text, - Some("Split merged cell".into()), - "split back: {:?}", - snap_of("status_left").text - ); -} diff --git a/crates/robius-ussd/src/lib.rs b/crates/robius-ussd/src/lib.rs index 40da08a..f136f45 100644 --- a/crates/robius-ussd/src/lib.rs +++ b/crates/robius-ussd/src/lib.rs @@ -444,58 +444,4 @@ pub fn dummy_session_events(amount: u64, kind: TransactionKind) -> Vec &'static str { - match self { - SimNetworkStatus::Active => "SIM Ready", - SimNetworkStatus::NoSim => "No SIM", - SimNetworkStatus::NoSignal => "No Signal", - SimNetworkStatus::AirplaneMode => "Airplane Mode", - SimNetworkStatus::Unknown => "Unknown", - } - } - - /// Returns `true` if USSD payments should be possible. - pub fn is_usable(&self) -> bool { - matches!(self, SimNetworkStatus::Active) - } -} - -/// Check the current SIM card / cellular network status. -/// -/// On Android this queries `TelephonyManager` via JNI to determine SIM -/// state, airplane mode, and network registration. On non-Android platforms -/// this returns `Unknown` (desktop apps don't have SIM cards). -pub fn sim_network_status() -> SimNetworkStatus { - sys::sim_network_status() -} \ No newline at end of file +pub const MPESA_USSD_CODE: &str = "*334#"; \ No newline at end of file diff --git a/crates/robius-ussd/src/mpesa_bands.rs b/crates/robius-ussd/src/mpesa_bands.rs index c401078..4b396a4 100644 --- a/crates/robius-ussd/src/mpesa_bands.rs +++ b/crates/robius-ussd/src/mpesa_bands.rs @@ -1,20 +1,27 @@ -//! Safaricom M-Pesa transaction fee bands (2026 tariff, verified Apr 2026). +//! Safaricom M-Pesa transaction fee bands (effective 2024+ tariff revision). //! -//! These are the *Send Money* / *Pochi la Biashara* / *Lipa na M-Pesa* -//! (Till & Paybill) / *Withdraw at Agent* fees published by Safaricom. -//! The bands are amounts in whole Kenyan Shillings (KSh). Amounts outside -//! the published range (e.g. > 250,000 for Send Money) are not supported -//! by M-Pesa and return `None`. +//! These are the *Send Money* / *Pochi* / *Lipa na M-Pesa* (Till & Paybill) / +//! *Withdraw at Agent* fees published by Safaricom. The bands are amounts in +//! whole Kenyan Shillings (KSh). Amounts outside the published range (e.g. +//! > 250,000 for Send Money) are not supported by M-Pesa and return `None`. //! -//! Source: Safaricom M-Pesa official consumer tariff table, last verified -//! 18 Aug 2026 against . -//! All fees include the 20% KRA excise duty. Tariff unchanged since 2023. +//! Source: Safaricom M-Pesa tariff guide (revised Jan 2024 onward). These +//! rates change occasionally — keep this file in sync when Safaricom revises. //! -//! ## Fee tiers +//! ## Two fee tiers //! -//! - **Send Money / Pochi** — identical tariff (Safaricom harmonised). -//! - **Till / Paybill** — customer-free for most bands; merchant pays. -//! - **Unregistered sends** — discontinued Feb 2024; kept for reference only. +//! For most transaction kinds, Safaricom charges **one fee when sending to +//! another registered M-Pesa user**, and a **different (lower) fee when +//! sending to an unregistered user / Till / Paybill**. We model both. +//! +//! | Transaction kind | Registered | Unregistered / Till / Paybill | +//! |---------------------------------------------|-----------|--------------------------------| +//! | Send Money (registered) | `send_to_registered` | n/a | +//! | Send Money (unregistered) | n/a | `send_to_unregistered` | +//! | Pochi la Biashara | `pochi` | n/a | +//! | Lipa na M-Pesa (Till) | `till` | n/a | +//! | Pay Bill | `paybill` | n/a | +//! | Withdraw at Agent | `withdraw_agent` | n/a | #![allow(dead_code)] @@ -59,7 +66,7 @@ impl MpesaFeeBandTable { } // ────────────────────────────────────────────────────────────────────────── -// Send Money → Registered M-Pesa user (2026 tariff, verified Apr 2026) +// Send Money → Registered M-Pesa user (effective Jan 2024) // ────────────────────────────────────────────────────────────────────────── pub const MPESA_SEND_REGISTERED: MpesaFeeBandTable = MpesaFeeBandTable { name: "Send Money → Registered", @@ -73,19 +80,18 @@ pub const MPESA_SEND_REGISTERED: MpesaFeeBandTable = MpesaFeeBandTable { MpesaFeeBand { min: 3_501, max: 5_000, fee: 57 }, MpesaFeeBand { min: 5_001, max: 7_500, fee: 78 }, MpesaFeeBand { min: 7_501, max: 10_000, fee: 90 }, - MpesaFeeBand { min: 10_001, max: 15_000, fee: 100 }, - MpesaFeeBand { min: 15_001, max: 20_000, fee: 105 }, - MpesaFeeBand { min: 20_001, max: 35_000, fee: 108 }, - MpesaFeeBand { min: 35_001, max: 50_000, fee: 108 }, - MpesaFeeBand { min: 50_001, max: 250_000, fee: 108 }, + MpesaFeeBand { min: 10_001, max: 15_000, fee: 97 }, + MpesaFeeBand { min: 15_001, max: 20_000, fee: 102 }, + MpesaFeeBand { min: 20_001, max: 35_000, fee: 105 }, + MpesaFeeBand { min: 35_001, max: 50_000, fee: 105 }, + MpesaFeeBand { min: 50_001, max: 150_000, fee: 105 }, + MpesaFeeBand { min: 150_001, max: 250_000, fee: 105 }, + MpesaFeeBand { min: 250_001, max: 500_000, fee: 105 }, ], }; // ────────────────────────────────────────────────────────────────────────── -// Send Money → Unregistered user (DISCONTINUED Feb 2024) -// -// Safaricom discontinued unregistered sends as an anti-fraud measure. -// Kept for reference / historical data only. No new code should use this. +// Send Money → Unregistered user (effective Jan 2024) // ────────────────────────────────────────────────────────────────────────── pub const MPESA_SEND_UNREGISTERED: MpesaFeeBandTable = MpesaFeeBandTable { name: "Send Money → Unregistered", @@ -107,9 +113,7 @@ pub const MPESA_SEND_UNREGISTERED: MpesaFeeBandTable = MpesaFeeBandTable { }; // ────────────────────────────────────────────────────────────────────────── -// Pochi la Biashara (2026 tariff — identical to Send Money registered) -// -// Safaricom harmonised Pochi with Send Money registered rates. +// Pochi la Biashara (effective Jan 2024) // ────────────────────────────────────────────────────────────────────────── pub const MPESA_POCHI: MpesaFeeBandTable = MpesaFeeBandTable { name: "Pochi la Biashara", @@ -117,22 +121,22 @@ pub const MPESA_POCHI: MpesaFeeBandTable = MpesaFeeBandTable { MpesaFeeBand { min: 1, max: 100, fee: 0 }, MpesaFeeBand { min: 101, max: 500, fee: 7 }, MpesaFeeBand { min: 501, max: 1_000, fee: 13 }, - MpesaFeeBand { min: 1_001, max: 1_500, fee: 23 }, - MpesaFeeBand { min: 1_501, max: 2_500, fee: 33 }, - MpesaFeeBand { min: 2_501, max: 3_500, fee: 53 }, - MpesaFeeBand { min: 3_501, max: 5_000, fee: 57 }, - MpesaFeeBand { min: 5_001, max: 7_500, fee: 78 }, - MpesaFeeBand { min: 7_501, max: 10_000, fee: 90 }, - MpesaFeeBand { min: 10_001, max: 15_000, fee: 100 }, - MpesaFeeBand { min: 15_001, max: 20_000, fee: 105 }, - MpesaFeeBand { min: 20_001, max: 35_000, fee: 108 }, - MpesaFeeBand { min: 35_001, max: 50_000, fee: 108 }, - MpesaFeeBand { min: 50_001, max: 250_000, fee: 108 }, + MpesaFeeBand { min: 1_001, max: 1_500, fee: 13 }, + MpesaFeeBand { min: 1_501, max: 2_500, fee: 16 }, + MpesaFeeBand { min: 2_501, max: 3_500, fee: 18 }, + MpesaFeeBand { min: 3_501, max: 5_000, fee: 18 }, + MpesaFeeBand { min: 5_001, max: 7_500, fee: 20 }, + MpesaFeeBand { min: 7_501, max: 10_000, fee: 20 }, + MpesaFeeBand { min: 10_001, max: 15_000, fee: 22 }, + MpesaFeeBand { min: 15_001, max: 20_000, fee: 22 }, + MpesaFeeBand { min: 20_001, max: 35_000, fee: 25 }, + MpesaFeeBand { min: 35_001, max: 50_000, fee: 27 }, + MpesaFeeBand { min: 50_001, max: 70_000, fee: 33 }, ], }; // ────────────────────────────────────────────────────────────────────────── -// Lipa na M-Pesa → Buy Goods (Till) (2026 tariff) +// Lipa na M-Pesa → Buy Goods (Till) (effective Jan 2024) // // Customer-paid fees for Till were ELIMINATED by Safaricom for most bands; // the merchant pays instead. We keep a 0-fee schedule so the UI can show @@ -146,7 +150,7 @@ pub const MPESA_TILL: MpesaFeeBandTable = MpesaFeeBandTable { }; // ────────────────────────────────────────────────────────────────────────── -// Lipa na M-Pesa → Pay Bill (2026 tariff) +// Lipa na M-Pesa → Pay Bill (effective Jan 2024) // // Most Paybill transactions are customer-free (fee paid by payee). A small // set of business paybills (e.g. utility companies) charge the customer; @@ -161,7 +165,7 @@ pub const MPESA_PAYBILL: MpesaFeeBandTable = MpesaFeeBandTable { }; // ────────────────────────────────────────────────────────────────────────── -// Withdraw Cash at Agent (2026 tariff) +// Withdraw Cash at Agent (effective Jan 2024) // ────────────────────────────────────────────────────────────────────────── pub const MPESA_WITHDRAW_AGENT: MpesaFeeBandTable = MpesaFeeBandTable { name: "Withdraw at Agent", @@ -257,28 +261,8 @@ mod tests { } #[test] - fn send_money_high_band_caps_at_108() { - assert_eq!(mpesa_fee_for_kind_amount(TransactionKind::SendMoney, 500_000), None); - assert_eq!(mpesa_fee_for_kind_amount(TransactionKind::SendMoney, 250_001), None); - assert_eq!(mpesa_fee_for_kind_amount(TransactionKind::SendMoney, 20_001), Some(108)); - assert_eq!(mpesa_fee_for_kind_amount(TransactionKind::SendMoney, 250_000), Some(108)); - } - - #[test] - fn send_money_mid_band_2026() { - assert_eq!(mpesa_fee_for_kind_amount(TransactionKind::SendMoney, 10_001), Some(100)); - assert_eq!(mpesa_fee_for_kind_amount(TransactionKind::SendMoney, 15_000), Some(100)); - assert_eq!(mpesa_fee_for_kind_amount(TransactionKind::SendMoney, 15_001), Some(105)); - assert_eq!(mpesa_fee_for_kind_amount(TransactionKind::SendMoney, 20_000), Some(105)); - } - - #[test] - fn pochi_matches_send_money() { - let amount = 1_500; - let sm = mpesa_fee_for_kind_amount(TransactionKind::SendMoney, amount); - let po = mpesa_fee_for_kind_amount(TransactionKind::Pochi, amount); - assert_eq!(sm, po); - assert_eq!(sm, Some(23)); + fn send_money_high_band_caps_at_105() { + assert_eq!(mpesa_fee_for_kind_amount(TransactionKind::SendMoney, 500_000), Some(105)); } #[test] diff --git a/crates/robius-ussd/src/sys/android/UssdAccessibilityService.java b/crates/robius-ussd/src/sys/android/UssdAccessibilityService.java index c4506f2..b7b1b13 100644 --- a/crates/robius-ussd/src/sys/android/UssdAccessibilityService.java +++ b/crates/robius-ussd/src/sys/android/UssdAccessibilityService.java @@ -83,18 +83,15 @@ public class UssdAccessibilityService extends AccessibilityService { private static final String STATE_WD_AMOUNT = "WD_AMOUNT"; private static final String STATE_WD_PIN = "WD_PIN"; - private static final String STATE_CONFIRM_1 = "CONFIRM_1"; - private static final String STATE_CONFIRM_CANCEL= "CONFIRM_CANCEL"; - private static final String STATE_DONE = "DONE"; - private static final String KEY_CONFIRM_RETRY = "confirm_retry"; - private static final String KEY_CANCEL_AT = "confirm_cancel_at"; - private static final int CONFIRM_MAX_RETRIES = 8; + private static final String STATE_CONFIRM_1 = "CONFIRM_1"; + private static final String STATE_DONE = "DONE"; + private static final String KEY_CONFIRM_RETRY = "confirm_retry"; + private static final int CONFIRM_MAX_RETRIES = 8; // Timing constants — match PesaMirror exactly. private static final long INITIAL_DELAY_MS = 1200L; private static final long STEP_DELAY_MS = 650L; private static final long CONFIRM_DELAY_MS = 1500L; - private static final long CONFIRM_CANCEL_DELAY_MS = 5000L; private static final long RESULT_THEN_CLOSE_DELAY_MS = 2000L; private static final long TIMEOUT_MS = 60_000L; @@ -180,13 +177,6 @@ public class UssdAccessibilityService extends AccessibilityService { if (TextUtils.isEmpty(state)) { prefs.edit().putString(KEY_STATE, firstState).apply(); handler.postDelayed(stepRunnable, INITIAL_DELAY_MS); - } else { - // Event-driven like PesaMirror: re-run the state machine on every - // relevant window/event change so a transition that the periodic - // self-schedule may have missed (e.g. the post-PIN confirmation - // menu appearing) is picked up. - handler.removeCallbacks(stepRunnable); - handler.post(stepRunnable); } } @@ -254,52 +244,13 @@ public class UssdAccessibilityService extends AccessibilityService { case STATE_WD_AMOUNT: typeField(prefs, root, prefs.getString(KEY_AMOUNT, ""), STATE_WD_PIN, 0.65f, "amount"); break; case STATE_WD_PIN: finishPin(prefs, root); break; - // ── Post-PIN confirmation menu: choose an option ── - // M-Pesa shows a menu after the PIN is accepted, e.g. - // "Send KSh 200 to +2547…" "1. Send" "2. Cancel" - // It has NO editable input field, so we must CLICK the numbered - // option node rather than typing a digit into an EditText (typing - // alone was why this step never completed). + // ── Optional confirmation step ── case STATE_CONFIRM_1: { - if (isPinDialogStillOpen(root)) { + if (rootContainsAnyText(root, java.util.Arrays.asList("Enter M-PESA PIN", "Enter M-Pesa PIN", "Enter PIN"))) { handler.postDelayed(stepRunnable, STEP_DELAY_MS); return; } - if (selectMenuOption(root, "1", "Send", "Confirm", "Yes") - || typeInInputAndSend(root, "1")) { - SharedPreferences prefs_local = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - prefs_local.edit().putString(KEY_STATE, STATE_DONE).apply(); - emitEvent(EVENT_STEP_COMPLETED, null, 1.0f, STATE_INT_DONE); - handler.removeCallbacks(closeUssdAfterResultRunnable); - handler.postDelayed(closeUssdAfterResultRunnable, RESULT_THEN_CLOSE_DELAY_MS); - } else { - SharedPreferences prefs_local = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - int retry = prefs_local.getInt(KEY_CONFIRM_RETRY, 0); - if (retry < CONFIRM_MAX_RETRIES) { - prefs_local.edit().putInt(KEY_CONFIRM_RETRY, retry + 1).apply(); - handler.postDelayed(stepRunnable, STEP_DELAY_MS); - } - } - break; - } - - // ── Post-PIN confirmation menu: cancel (user opted to) ── - case STATE_CONFIRM_CANCEL: { - if (isPinDialogStillOpen(root)) { - handler.postDelayed(stepRunnable, STEP_DELAY_MS); - return; - } - // Honor the 5s wait even when event-driven re-posts fire - // before the deadline: only cancel once the delay has elapsed. - long cancelAt = prefs.getLong(KEY_CANCEL_AT, 0L); - long remaining = cancelAt - System.currentTimeMillis(); - if (remaining > 0) { - handler.postDelayed(stepRunnable, remaining + 50L); - return; - } - if (selectMenuOption(root, "2", "Cancel", "No")) { - SharedPreferences prefs_local = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); - prefs_local.edit().putString(KEY_STATE, STATE_DONE).apply(); + if (typeInInputAndSend(root, "1")) { emitEvent(EVENT_STEP_COMPLETED, null, 1.0f, STATE_INT_DONE); handler.removeCallbacks(closeUssdAfterResultRunnable); handler.postDelayed(closeUssdAfterResultRunnable, RESULT_THEN_CLOSE_DELAY_MS); @@ -319,22 +270,6 @@ public class UssdAccessibilityService extends AccessibilityService { } } - private boolean isPinDialogStillOpen(AccessibilityNodeInfo root) { - return rootContainsAnyText(root, java.util.Arrays.asList( - "Enter M-PESA PIN", "Enter M-Pesa PIN", "Enter PIN")); - } - - // Selects a numbered menu option. First tries clicking an existing node - // whose text/description is the digit or "N. …"/"N …" (menu options), then - // falls back to clicking any node labelled with one of the friendly words. - private boolean selectMenuOption(AccessibilityNodeInfo root, String digit, String... friendlyWords) { - if (clickNodeWithText(root, digit)) return true; - for (String word : friendlyWords) { - if (clickNodeWithText(root, word)) return true; - } - return false; - } - private void typeAndSend(SharedPreferences prefs, AccessibilityNodeInfo root, String digit, String nextState, float progress) { if (!typeInInputAndSend(root, digit)) { handler.postDelayed(stepRunnable, STEP_DELAY_MS); @@ -357,42 +292,12 @@ public class UssdAccessibilityService extends AccessibilityService { } private void finishPin(SharedPreferences prefs, AccessibilityNodeInfo root) { - String pin = prefs.getString(KEY_PIN, ""); - if (TextUtils.isEmpty(pin)) { - fail("pin_not_set"); - return; - } - // Prefer the field whose hint/description contains "pin", "mpesa", or - // "enter" so we never accidentally type the PIN into a confirmation - // input or the wrong editable field. - boolean pinSet = setTextOnNodeWithHint(root, pin, "pin", "mpesa", "enter") - || setTextOnFocusedOrFirstEditable(root, pin); - if (!pinSet) { - handler.postDelayed(stepRunnable, STEP_DELAY_MS); - return; - } - clickSendOrOk(root); - - boolean confirmSend = prefs.getBoolean(KEY_CONFIRM, false); - if (confirmSend) { - // Confirm checkbox ON: wait CONFIRM_CANCEL_DELAY_MS, then choose - // "2"/"Cancel" on the post-PIN confirmation menu. - long cancelAt = System.currentTimeMillis() + CONFIRM_CANCEL_DELAY_MS; - prefs.edit() - .putString(KEY_STATE, STATE_CONFIRM_CANCEL) - .putLong(KEY_CANCEL_AT, cancelAt) - .putInt(KEY_CONFIRM_RETRY, 0) - .apply(); - handler.postDelayed(stepRunnable, CONFIRM_CANCEL_DELAY_MS); - } else { - // Confirm checkbox OFF: proceed immediately on the post-PIN - // confirmation menu (choose "1"/"Send"). - prefs.edit() - .putString(KEY_STATE, STATE_CONFIRM_1) - .putInt(KEY_CONFIRM_RETRY, 0) - .apply(); - handler.postDelayed(stepRunnable, CONFIRM_DELAY_MS); - } + // Phase-0 safety gate: Nigig is tracker/launcher-only until an + // authorised provider integration exists. PIN handoff from Rust to an + // AccessibilityService is deliberately disabled. Remove a stale key + // from older versions and fail closed rather than type a credential. + prefs.edit().remove(KEY_PIN).apply(); + fail("pin_handoff_disabled"); } private void closeUssdAfterResult() { @@ -612,12 +517,10 @@ public class UssdAccessibilityService extends AccessibilityService { String nodeText = node.getText() != null ? node.getText().toString().trim() : ""; String desc = node.getContentDescription() != null ? node.getContentDescription().toString().trim() : ""; boolean match; - if (text.length() == 1 && Character.isDigit(text.charAt(0))) { - // A single digit matches an exact "N", a "N. …" / "N …" menu option, - // or a node whose description carries the same digit prefix. - match = nodeText.equals(text) || desc.equals(text) - || nodeText.startsWith(text + ".") || nodeText.startsWith(text + " ") - || desc.startsWith(text + ".") || desc.startsWith(text + " "); + if ("1".equals(text)) { + match = "1".equals(nodeText) || "1".equals(desc) + || nodeText.startsWith("1.") || nodeText.startsWith("1 ") + || desc.startsWith("1.") || desc.startsWith("1 "); } else { match = text.equalsIgnoreCase(nodeText) || text.equalsIgnoreCase(desc); } diff --git a/crates/robius-ussd/src/sys/android/accessibility.rs b/crates/robius-ussd/src/sys/android/accessibility.rs index db9fd1d..c92fafd 100644 --- a/crates/robius-ussd/src/sys/android/accessibility.rs +++ b/crates/robius-ussd/src/sys/android/accessibility.rs @@ -95,21 +95,14 @@ pub(crate) fn ensure_loaded() -> Result<()> { .l()?; let class_name = env.new_string("robius.ussd.UssdAccessibilityService")?; - let load_result = env.call_method( - &class_loader, - "loadClass", - "(Ljava/lang/String;)Ljava/lang/Class;", - &[JValueGen::Object(&JObject::from(class_name))], - ); - let class_obj = match load_result { - Ok(val) => val.l()?, - Err(_) => { - // Clear the pending Java exception (e.g. ClassNotFoundException) - // so it doesn't poison subsequent JNI calls and abort the process. - let _ = env.exception_clear(); - return Ok(()); - } - }; + let class_obj = env + .call_method( + &class_loader, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[JValueGen::Object(&JObject::from(class_name))], + )? + .l()?; let class_ref: JClass = class_obj.into(); diff --git a/crates/robius-ussd/src/sys/android/mod.rs b/crates/robius-ussd/src/sys/android/mod.rs index 807018e..d604970 100644 --- a/crates/robius-ussd/src/sys/android/mod.rs +++ b/crates/robius-ussd/src/sys/android/mod.rs @@ -115,77 +115,3 @@ pub(crate) fn current_state() -> Result { pub(crate) fn next_event() -> Option { events::next_event() } - -/// Check SIM card / cellular network status via Android TelephonyManager. -/// -/// This is the authoritative source for whether USSD can work — USSD rides -/// the cellular signalling channel, not Wi-Fi or mobile data. -fn check_sim_inner(env: &mut jni::JNIEnv, activity: &jni::objects::JObject) -> std::result::Result { - use crate::SimNetworkStatus; - - // Settings.Global.getString requires a ContentResolver, not the Activity directly. - let resolver = env.call_method( - activity, - "getContentResolver", - "()Landroid/content/ContentResolver;", - &[], - )?.l()?; - - let settings_class = env.find_class("android/provider/Settings$Global")?; - let airplane_key = env.new_string("airplane_mode_on")?; - // Use getInt with a default of 0 — simpler than getString and avoids null conversion. - let airplane_on: i32 = env.call_static_method( - &settings_class, - "getInt", - "(Landroid/content/ContentResolver;Ljava/lang/String;I)I", - &[(&resolver).into(), (&airplane_key).into(), 0i32.into()], - )?.i()?; - if airplane_on == 1 { - return Ok(SimNetworkStatus::AirplaneMode); - } - - let telephony_class = env.find_class("android/telephony/TelephonyManager")?; - let tm_obj = env.call_method( - activity, - "getSystemService", - "(Ljava/lang/String;)Ljava/lang/Object;", - &[(&env.new_string("phone")?).into()], - )?.l()?; - - if tm_obj.is_null() { - return Ok(SimNetworkStatus::Unknown); - } - - let sim_state: i32 = env.call_method( - &tm_obj, - "getSimState", - "()I", - &[], - )?.i()?; - - if sim_state == 1 || sim_state == 0 { - return Ok(SimNetworkStatus::NoSim); - } - - let operator_obj = env.call_method( - &tm_obj, - "getNetworkOperator", - "()Ljava/lang/String;", - &[], - )?.l()?; - let operator: String = env.get_string(&operator_obj.into())?.into(); - - if operator.is_empty() { - return Ok(SimNetworkStatus::NoSignal); - } - - Ok(SimNetworkStatus::Active) -} - -pub(crate) fn sim_network_status() -> crate::SimNetworkStatus { - use crate::SimNetworkStatus; - match robius_android_env::with_activity(check_sim_inner) { - Ok(Ok(status)) => status, - _ => SimNetworkStatus::Unknown, - } -} diff --git a/crates/robius-ussd/src/sys/android/session.rs b/crates/robius-ussd/src/sys/android/session.rs index aa8c2e8..b5347a9 100644 --- a/crates/robius-ussd/src/sys/android/session.rs +++ b/crates/robius-ussd/src/sys/android/session.rs @@ -54,12 +54,10 @@ pub(crate) fn write_request(request: &UssdTransactionRequest) -> Result<()> { .to_string(); put_string(env, &editor, KEY_MODE, &mode)?; put_string(env, &editor, KEY_AMOUNT, &request.amount.to_string())?; - // The AccessibilityService reads this key to type the M-Pesa PIN - // into the USSD dialog. The PIN is a short-lived transient: it lives - // in SharedPreferences only for the duration of the USSD session and - // is scrubbed by both `closeUssdAfterResult()` and `fail()` on the - // Java side, and by `clear()` on the Rust side. - put_string(env, &editor, KEY_PIN, &request.pin)?; + // Phase-0 safety gate: the legacy AccessibilityService must never + // receive or persist an M-Pesa PIN. Remove a stale key left by older + // app versions before publishing any session state. + remove_key(env, &editor, KEY_PIN)?; put_string(env, &editor, KEY_PHONE, &request.phone)?; put_string(env, &editor, KEY_TILL, &request.till)?; put_string(env, &editor, KEY_BUSINESS, &request.business)?; diff --git a/crates/robius-ussd/src/sys/apple.rs b/crates/robius-ussd/src/sys/apple.rs index 4c07dcc..5898b5f 100644 --- a/crates/robius-ussd/src/sys/apple.rs +++ b/crates/robius-ussd/src/sys/apple.rs @@ -30,6 +30,4 @@ pub(crate) fn cancel_session() -> Result<()> { Ok(()) } pub(crate) fn current_state() -> Result { Ok(SessionState::Idle) } -pub(crate) fn next_event() -> Option { None } - -pub(crate) fn sim_network_status() -> crate::SimNetworkStatus { crate::SimNetworkStatus::Unknown } \ No newline at end of file +pub(crate) fn next_event() -> Option { None } \ No newline at end of file diff --git a/crates/robius-ussd/src/sys/ios_trollstore.rs b/crates/robius-ussd/src/sys/ios_trollstore.rs index fc04f84..cb5a96f 100644 --- a/crates/robius-ussd/src/sys/ios_trollstore.rs +++ b/crates/robius-ussd/src/sys/ios_trollstore.rs @@ -77,6 +77,4 @@ pub(crate) fn send_ussd_code(code: &str) -> Result { Err(Error::Unknown) } } -} - -pub(crate) fn sim_network_status() -> crate::SimNetworkStatus { crate::SimNetworkStatus::Unknown } \ No newline at end of file +} \ No newline at end of file diff --git a/crates/robius-ussd/src/sys/linux.rs b/crates/robius-ussd/src/sys/linux.rs index 871d404..b54c949 100644 --- a/crates/robius-ussd/src/sys/linux.rs +++ b/crates/robius-ussd/src/sys/linux.rs @@ -31,6 +31,4 @@ pub(crate) fn cancel_session() -> Result<()> { Ok(()) } pub(crate) fn current_state() -> Result { Ok(SessionState::Idle) } -pub(crate) fn next_event() -> Option { None } - -pub(crate) fn sim_network_status() -> crate::SimNetworkStatus { crate::SimNetworkStatus::Unknown } \ No newline at end of file +pub(crate) fn next_event() -> Option { None } \ No newline at end of file diff --git a/crates/robius-ussd/src/sys/unsupported.rs b/crates/robius-ussd/src/sys/unsupported.rs index 713d982..812c03c 100644 --- a/crates/robius-ussd/src/sys/unsupported.rs +++ b/crates/robius-ussd/src/sys/unsupported.rs @@ -31,6 +31,4 @@ pub(crate) fn cancel_session() -> Result<()> { Ok(()) } pub(crate) fn current_state() -> Result { Ok(SessionState::Idle) } -pub(crate) fn next_event() -> Option { None } - -pub(crate) fn sim_network_status() -> crate::SimNetworkStatus { crate::SimNetworkStatus::Unknown } \ No newline at end of file +pub(crate) fn next_event() -> Option { None } \ No newline at end of file diff --git a/crates/robius-ussd/src/sys/windows.rs b/crates/robius-ussd/src/sys/windows.rs index af3d1df..d1fd9e6 100644 --- a/crates/robius-ussd/src/sys/windows.rs +++ b/crates/robius-ussd/src/sys/windows.rs @@ -31,6 +31,4 @@ pub(crate) fn cancel_session() -> Result<()> { Ok(()) } pub(crate) fn current_state() -> Result { Ok(SessionState::Idle) } -pub(crate) fn next_event() -> Option { None } - -pub(crate) fn sim_network_status() -> crate::SimNetworkStatus { crate::SimNetworkStatus::Unknown } \ No newline at end of file +pub(crate) fn next_event() -> Option { None } \ No newline at end of file diff --git a/ui/Old_Mutual_Finance_Budget_Tool.xls b/ui/Old_Mutual_Finance_Budget_Tool.xls deleted file mode 100644 index 985e48f..0000000 Binary files a/ui/Old_Mutual_Finance_Budget_Tool.xls and /dev/null differ