diff --git a/.gitignore b/.gitignore index 5606b50..db66294 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /result *.swp .direnv/ +/bone-documents/ diff --git a/Cargo.lock b/Cargo.lock index b3449a9..319bdc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,7 +40,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -95,6 +95,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "approx" version = "0.5.1" @@ -131,6 +137,151 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.13.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340e0f6bf7f9ee78549c61454f1460a3ed97c011902ee76b58301bbc6d502a32" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.2", + "serde", + "serde_repr", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -205,15 +356,30 @@ dependencies = [ "objc2 0.6.4", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bone-app" version = "0.0.0" dependencies = [ + "ashpd", "bone-document", "bone-render", "bone-text", "bone-types", "bone-ui", + "percent-encoding", "pollster", "swash", "thiserror 2.0.18", @@ -639,6 +805,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -651,6 +823,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equator" version = "0.2.2" @@ -711,6 +904,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "faer" version = "0.24.0" @@ -860,6 +1074,36 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "futures-task" version = "0.3.32" @@ -873,7 +1117,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", + "futures-macro", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1019,8 +1266,21 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", "wasip2", + "wasip3", ] [[package]] @@ -1162,6 +1422,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hexf-parse" version = "0.2.1" @@ -1295,6 +1561,12 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.14.0" @@ -1303,6 +1575,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] @@ -1330,6 +1604,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.22.4" @@ -1394,7 +1674,7 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom", + "getrandom 0.3.4", "libc", ] @@ -1433,6 +1713,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.185" @@ -1578,6 +1864,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2104,6 +2399,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -2136,6 +2441,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2230,6 +2541,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2316,6 +2638,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "private-gemm-x86" version = "0.1.20" @@ -2426,6 +2758,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.9.4" @@ -2452,7 +2790,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] @@ -2726,6 +3064,30 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2741,6 +3103,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simba" version = "0.9.1" @@ -2962,7 +3334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -3171,6 +3543,17 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unarray" version = "0.1.4" @@ -3195,6 +3578,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "uom" version = "0.38.0" @@ -3216,6 +3605,11 @@ name = "uuid" version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] [[package]] name = "valuable" @@ -3245,7 +3639,16 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -3303,6 +3706,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.15" @@ -3880,12 +4317,100 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.3" @@ -3984,6 +4509,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + [[package]] name = "zeno" version = "0.3.3" @@ -4065,3 +4651,49 @@ dependencies = [ "quote", "syn 2.0.117", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index a1cfaa3..a2f674f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ bone-text = { path = "crates/bone-text" } bone-ui = { path = "crates/bone-ui" } accesskit = "0.24" +ashpd = { version = "0.13", default-features = false, features = ["async-io", "file_chooser"] } blake3 = { version = "1", default-features = false, features = ["std"] } bytemuck = { version = "1", default-features = false, features = ["derive"] } faer = { version = "0.24", default-features = false, features = ["std"] } @@ -48,6 +49,7 @@ lyon_tessellation = "1" nalgebra = { version = "0.33", default-features = false, features = ["std"] } palette = { version = "0.7", default-features = false, features = ["std"] } parley = { version = "0.9", default-features = false, features = ["std"] } +percent-encoding = { version = "2", default-features = false, features = ["std"] } png = { version = "0.17", default-features = false } pollster = "0.4" proptest = { version = "1", default-features = false, features = ["std"] } diff --git a/crates/bone-app/Cargo.toml b/crates/bone-app/Cargo.toml index 5300b46..8abe4e1 100644 --- a/crates/bone-app/Cargo.toml +++ b/crates/bone-app/Cargo.toml @@ -21,5 +21,9 @@ tracing-subscriber = { workspace = true } uom = { workspace = true } winit = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +ashpd = { workspace = true } +percent-encoding = { workspace = true } + [lints] workspace = true diff --git a/crates/bone-app/src/file_menu.rs b/crates/bone-app/src/file_menu.rs new file mode 100644 index 0000000..8838e94 --- /dev/null +++ b/crates/bone-app/src/file_menu.rs @@ -0,0 +1,498 @@ +use std::path::{Component, Path, PathBuf}; + +use bone_ui::frame::FrameCtx; +use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; +use bone_ui::widgets::{ + FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerOutcome, FilePickerState, + LabelText, WidgetPaint, show_file_picker, +}; +use bone_ui::{WidgetId, WidgetKey}; + +use crate::strings; + +pub const DEFAULT_DOCUMENTS_SUBDIR: &str = "bone-documents"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PickerEntry { + pub id: WidgetId, + pub label: String, + pub path: PathBuf, +} + +pub struct FilePickerSession { + pub mode: FilePickerMode, + pub root: PathBuf, + pub entries: Vec, + pub state: FilePickerState, +} + +impl FilePickerSession { + #[must_use] + pub fn open( + root: PathBuf, + mode: FilePickerMode, + seed_filename: Option, + entries: Vec, + ) -> Self { + let mut state = FilePickerState::default(); + if let Some(name) = seed_filename { + state.filename.text = name; + } + Self { + mode, + root, + entries, + state, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PickerCommand { + Cancel, + OpenFolder(PathBuf), + SaveAs(PathBuf), +} + +pub struct PickerModalOutcome { + pub paints: Vec, + pub command: Option, +} + +pub fn render( + ctx: &mut FrameCtx<'_>, + session: &mut FilePickerSession, + viewport: LayoutSize, +) -> PickerModalOutcome { + let viewport_rect = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), viewport); + let entries: Vec = session + .entries + .iter() + .map(|e| FilePickerEntry { + id: e.id, + label: LabelText::Owned(e.label.clone()), + }) + .collect(); + let title = match session.mode { + FilePickerMode::Open => strings::FILE_PICKER_TITLE_OPEN, + FilePickerMode::Save => strings::FILE_PICKER_TITLE_SAVE_AS, + }; + let current_path = session.root.display().to_string(); + let entries_empty = entries.is_empty(); + let response = show_file_picker( + ctx, + FilePickerDialog::new( + picker_id(), + viewport_rect, + session.mode, + current_path, + &entries, + title, + &mut session.state, + ), + ); + let mut paints = response.paint; + if entries_empty { + paints.push(empty_state_label(ctx, viewport_rect)); + } + let command = response + .outcome + .and_then(|o| translate(o, &session.entries, &session.root)); + PickerModalOutcome { paints, command } +} + +fn empty_state_label(ctx: &FrameCtx<'_>, viewport: LayoutRect) -> WidgetPaint { + let rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(viewport.origin.x.value() + viewport.size.width.value() * 0.5 - 160.0), + LayoutPx::new(viewport.origin.y.value() + viewport.size.height.value() * 0.5 - 12.0), + ), + LayoutSize::new(LayoutPx::new(320.0), LayoutPx::new(24.0)), + ); + WidgetPaint::Label { + rect, + text: LabelText::Key(strings::FILE_PICKER_DIR_EMPTY), + color: ctx.theme().colors.text_secondary(), + role: ctx.theme().typography.body, + } +} + +fn translate( + outcome: FilePickerOutcome, + entries: &[PickerEntry], + root: &Path, +) -> Option { + match outcome { + FilePickerOutcome::Cancelled => Some(PickerCommand::Cancel), + FilePickerOutcome::Open { folder } => entries + .iter() + .find(|e| e.id == folder) + .map(|e| PickerCommand::OpenFolder(e.path.clone())), + FilePickerOutcome::Save { folder, filename } => { + if filename.is_empty() { + return folder + .and_then(|f| entries.iter().find(|e| e.id == f)) + .map(|e| PickerCommand::SaveAs(e.path.clone())); + } + let Some(name) = validate_save_name(filename.as_str()) else { + tracing::warn!(input = filename.as_str(), "rejected save filename"); + return None; + }; + Some(PickerCommand::SaveAs(root.join(name))) + } + } +} + +const WINDOWS_RESERVED: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +]; +const WINDOWS_RESERVED_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*']; +pub const MAX_SAVE_NAME_BYTES: usize = 200; + +#[must_use] +pub fn validate_save_name(name: &str) -> Option<&str> { + if name.is_empty() || name.trim().is_empty() { + return None; + } + if name.len() > MAX_SAVE_NAME_BYTES { + return None; + } + if name != name.trim() { + return None; + } + if name.ends_with('.') { + return None; + } + if name + .chars() + .any(|c| c.is_control() || WINDOWS_RESERVED_CHARS.contains(&c)) + { + return None; + } + let stem = name.split('.').next().unwrap_or(""); + if WINDOWS_RESERVED + .iter() + .any(|r| stem.eq_ignore_ascii_case(r)) + { + return None; + } + let path = Path::new(name); + let mut components = path.components(); + let first = components.next()?; + if components.next().is_some() { + return None; + } + match first { + Component::Normal(os) => os.to_str().filter(|s| !s.is_empty()), + Component::CurDir | Component::ParentDir | Component::Prefix(_) | Component::RootDir => { + None + } + } +} + +pub fn picker_id() -> WidgetId { + WidgetId::ROOT.child(WidgetKey::new("app.file_picker")) +} + +pub fn scan_document_folders(root: &Path) -> Result, std::io::Error> { + let read = match std::fs::read_dir(root) { + Ok(read) => read, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e), + }; + let mut entries: Vec = read + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + let document_ron = path.join(bone_document::io::folder::DOCUMENT_FILE); + let is_doc = path.is_dir() && document_ron.is_file(); + if !is_doc { + return None; + } + let label = path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let id = WidgetId::ROOT + .child(WidgetKey::new("app.file_picker.entry")) + .child_named(WidgetKey::new("name"), &label); + Some(PickerEntry { id, label, path }) + }) + .collect(); + entries.sort_by(|a, b| a.label.cmp(&b.label)); + Ok(entries) +} + +#[must_use] +pub fn documents_root() -> PathBuf { + if let Some(path) = std::env::var_os("BONE_DOCUMENTS_DIR") { + return PathBuf::from(path); + } + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + cwd.join(DEFAULT_DOCUMENTS_SUBDIR) +} + +#[cfg(test)] +mod tests { + use super::{ + FilePickerSession, PickerCommand, scan_document_folders, translate, validate_save_name, + }; + use bone_ui::widgets::{FilePickerMode, FilePickerOutcome}; + use std::fs; + use std::path::PathBuf; + + fn tmp(prefix: &str) -> PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + let base = std::env::temp_dir().join(format!("bone-file-menu-{prefix}-{nonce}")); + match fs::create_dir_all(&base) { + Ok(()) => base, + Err(e) => panic!("temp dir create failed: {e}"), + } + } + + fn write_stub(path: &std::path::Path, body: &str) { + if let Some(parent) = path.parent() + && let Err(e) = fs::create_dir_all(parent) + { + panic!("create parent {}: {e}", parent.display()); + } + if let Err(e) = fs::write(path, body) { + panic!("write stub {}: {e}", path.display()); + } + } + + const RON_STUB: &str = "(schema:(name:\"bone-document\",version:(major:1,minor:0)))"; + + fn scan(root: &std::path::Path) -> Vec { + match scan_document_folders(root) { + Ok(v) => v, + Err(e) => panic!("scan {}: {e}", root.display()), + } + } + + #[test] + fn scan_picks_only_document_folders() { + let root = tmp("scan"); + write_stub(&root.join("doc_a/document.ron"), "stub"); + write_stub(&root.join("doc_b/document.ron"), "stub"); + write_stub(&root.join("not_a_doc/.keep"), ""); + write_stub(&root.join("loose-file.txt"), "x"); + let entries = scan(&root); + assert_eq!(entries.len(), 2); + let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect(); + assert_eq!(labels, ["doc_a", "doc_b"]); + } + + #[test] + fn scan_missing_root_is_ok_empty() { + let root = tmp("scan-missing").join("does_not_exist"); + let entries = scan_document_folders(&root); + assert!(matches!(entries, Ok(v) if v.is_empty())); + } + + #[test] + fn scan_ids_are_stable_across_reruns() { + let root = tmp("scan-stable"); + write_stub(&root.join("alpha/document.ron"), RON_STUB); + write_stub(&root.join("beta/document.ron"), RON_STUB); + let first = scan(&root); + let second = scan(&root); + assert_eq!( + first.iter().map(|e| e.id).collect::>(), + second.iter().map(|e| e.id).collect::>(), + ); + } + + #[test] + fn open_session_seeds_filename() { + let root = tmp("seed"); + let session = FilePickerSession::open( + root, + FilePickerMode::Save, + Some("Untitled".to_owned()), + Vec::new(), + ); + assert_eq!(session.state.filename.text, "Untitled"); + } + + #[test] + fn translate_open_resolves_widget_id_to_path() { + let root = tmp("translate-open"); + write_stub(&root.join("alpha/document.ron"), RON_STUB); + let entries = scan(&root); + let cmd = translate( + FilePickerOutcome::Open { + folder: entries[0].id, + }, + &entries, + &root, + ); + assert_eq!( + cmd, + Some(PickerCommand::OpenFolder(entries[0].path.clone())) + ); + } + + #[test] + fn translate_save_uses_filename_field_under_root() { + let root = tmp("translate-save"); + let entries = scan(&root); + let cmd = translate( + FilePickerOutcome::Save { + folder: None, + filename: "brand_new".into(), + }, + &entries, + &root, + ); + assert_eq!(cmd, Some(PickerCommand::SaveAs(root.join("brand_new")))); + } + + #[test] + fn translate_cancel_is_cancel() { + let root = tmp("translate-cancel"); + let entries = scan(&root); + let cmd = translate(FilePickerOutcome::Cancelled, &entries, &root); + assert_eq!(cmd, Some(PickerCommand::Cancel)); + } + + #[test] + fn translate_save_empty_filename_falls_back_to_selection() { + let root = tmp("translate-save-fallback"); + write_stub(&root.join("seed/document.ron"), RON_STUB); + let entries = scan(&root); + let cmd = translate( + FilePickerOutcome::Save { + folder: Some(entries[0].id), + filename: String::new(), + }, + &entries, + &root, + ); + assert_eq!(cmd, Some(PickerCommand::SaveAs(entries[0].path.clone()))); + } + + #[test] + fn translate_save_rejects_whitespace_only_filename() { + let root = tmp("translate-save-whitespace"); + write_stub(&root.join("seed/document.ron"), RON_STUB); + let entries = scan(&root); + let cmd = translate( + FilePickerOutcome::Save { + folder: Some(entries[0].id), + filename: " ".into(), + }, + &entries, + &root, + ); + assert_eq!(cmd, None); + } + + #[test] + fn validate_accepts_simple_name() { + assert_eq!(validate_save_name("Untitled"), Some("Untitled")); + assert_eq!(validate_save_name("doc_42"), Some("doc_42")); + assert_eq!(validate_save_name("a-b.c"), Some("a-b.c")); + } + + #[test] + fn validate_rejects_path_escape_and_separators() { + assert_eq!(validate_save_name(""), None); + assert_eq!(validate_save_name(".."), None); + assert_eq!(validate_save_name("."), None); + assert_eq!(validate_save_name("../escape"), None); + assert_eq!(validate_save_name("a/b"), None); + #[cfg(windows)] + assert_eq!(validate_save_name(r"a\b"), None); + #[cfg(unix)] + assert_eq!(validate_save_name("/abs/path"), None); + } + + #[test] + fn validate_rejects_windows_reserved_stems() { + assert_eq!(validate_save_name("CON"), None); + assert_eq!(validate_save_name("con"), None); + assert_eq!(validate_save_name("NUL.txt"), None); + assert_eq!(validate_save_name("COM1"), None); + assert_eq!(validate_save_name("LPT9.bak"), None); + assert_eq!(validate_save_name("AUX"), None); + } + + #[test] + fn validate_rejects_windows_reserved_chars() { + assert_eq!(validate_save_name("foo, pending_exit: bool, + current_folder: Option, + documents_root: PathBuf, + file_picker: Option, + native_picker: Option, + pending_overwrite: Option, + last_saved: Option, + pending_discard: Option, + notification: Option, +} + +#[derive(Clone, Debug, PartialEq)] +enum PendingDiscard { + New, + Open(PathBuf), +} + +fn modal_active(state: &RenderState) -> bool { + state.file_picker.is_some() + || state.native_picker.is_some() + || state.pending_overwrite.is_some() + || state.pending_discard.is_some() +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum NotificationKind { + Info, + Error, +} + +#[derive(Clone, Debug, PartialEq)] +struct Notification { + kind: NotificationKind, + headline: bone_ui::strings::StringKey, + detail: Option, } -#[derive(Default)] struct InputState { cursor_px: Option>, left_pan: bool, @@ -144,6 +179,23 @@ struct InputState { pending_released: PointerButtonMask, pending_keys: Vec, pending_text: String, + start: std::time::Instant, +} + +impl Default for InputState { + fn default() -> Self { + Self { + cursor_px: None, + left_pan: false, + middle_pan: false, + modifiers: ModifiersState::empty(), + pending_pressed: PointerButtonMask::EMPTY, + pending_released: PointerButtonMask::EMPTY, + pending_keys: Vec::new(), + pending_text: String::new(), + start: std::time::Instant::now(), + } + } } impl InputState { @@ -184,7 +236,7 @@ impl InputState { } fn drain_snapshot(&mut self) -> InputSnapshot { - let mut snap = InputSnapshot::idle(FrameInstant::ZERO); + let mut snap = InputSnapshot::idle(FrameInstant::from_duration(self.start.elapsed())); snap.pointer = self.pointer_sample(); snap.buttons_pressed = core::mem::replace(&mut self.pending_pressed, PointerButtonMask::EMPTY); @@ -801,6 +853,7 @@ impl ApplicationHandler for App { let theme = Arc::new(Theme::light()); let shell = shell::Shell::new(); let (document, sketch_id) = initial_document(sketch); + let last_saved_baseline = document.clone(); let plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); let strings = strings::make_strings(bone_ui::strings::Locale::EnUs); let viewport_rect = empty_rect(); @@ -837,6 +890,14 @@ impl ApplicationHandler for App { dim_editor: DimensionEditorState::default(), dim_editor_bounds: None, pending_exit: false, + current_folder: None, + documents_root: file_menu::documents_root(), + file_picker: None, + native_picker: None, + pending_overwrite: None, + last_saved: Some(last_saved_baseline), + pending_discard: None, + notification: None, }); } @@ -853,6 +914,15 @@ impl ApplicationHandler for App { event::AppEvent::Ignored => {} } } + + fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: winit::event::StartCause) { + if matches!(cause, winit::event::StartCause::ResumeTimeReached { .. }) + && let Some(scheduler) = self.redraw.as_mut() + { + scheduler.kick(); + scheduler.window().request_redraw(); + } + } } impl App { @@ -883,11 +953,14 @@ impl App { event::InputEvent::CursorMove(position) => { let prev = self.input.cursor_px; self.input.cursor_px = Some(position); - if self.input.panning() + let modal = modal_active(state); + if !modal + && self.input.panning() && let Some(p) = prev { state.camera = pan_by_px(state.camera, position.x - p.x, position.y - p.y); - } else if dragging_in_sketch(&state.mode) + } else if !modal + && dragging_in_sketch(&state.mode) && let Some(world) = cursor_to_world(state.camera, position) { try_drag_to(state, world); @@ -897,12 +970,16 @@ impl App { self.input.cursor_px = None; } event::InputEvent::CursorEntered => {} - event::InputEvent::Pointer { button, state: btn_state } => { + event::InputEvent::Pointer { + button, + state: btn_state, + } => { self.dispatch_pointer_button(button, btn_state); } event::InputEvent::Wheel(delta) => { - if self.input.cursor_in(state.viewport_rect) { - state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); + if !modal_active(state) && self.input.cursor_in(state.viewport_rect) { + state.camera = + zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); } } event::InputEvent::KeyDown { @@ -917,7 +994,10 @@ impl App { } fn dispatch_pointer_button(&mut self, button: MouseButton, btn_state: ElementState) { - let Some(state) = self.render.as_mut() else { return }; + let Some(state) = self.render.as_mut() else { + return; + }; + let modal = modal_active(state); match button { MouseButton::Left => { if btn_state == ElementState::Pressed { @@ -926,10 +1006,12 @@ impl App { .dim_editor_bounds .is_some_and(|r| self.input.cursor_in(r)); let dim_active = dim_flow_active(&state.mode); - self.input.left_pan = in_viewport && !over_dim_editor && !dim_active; + self.input.left_pan = + !modal && in_viewport && !over_dim_editor && !dim_active; self.input.pending_pressed = self.input.pending_pressed.with(PointerButton::Primary); - if in_viewport + if !modal + && in_viewport && !over_dim_editor && !dim_active && !self.input.modifiers.shift_key() @@ -956,7 +1038,7 @@ impl App { if btn_state == ElementState::Pressed { self.input.pending_pressed = self.input.pending_pressed.with(PointerButton::Secondary); - if self.input.cursor_in(state.viewport_rect) { + if !modal && self.input.cursor_in(state.viewport_rect) { state.mode = core::mem::take(&mut state.mode).clear_pending(); } } else { @@ -966,7 +1048,8 @@ impl App { } MouseButton::Middle => { if btn_state == ElementState::Pressed { - self.input.middle_pan = self.input.cursor_in(state.viewport_rect); + self.input.middle_pan = + !modal && self.input.cursor_in(state.viewport_rect); self.input.pending_pressed = self.input.pending_pressed.with(PointerButton::Middle); } else { @@ -986,7 +1069,9 @@ impl App { logical_key: &Key, text: Option<&winit::keyboard::SmolStr>, ) { - let Some(state) = self.render.as_mut() else { return }; + let Some(state) = self.render.as_mut() else { + return; + }; let physical_code = match physical_key { PhysicalKey::Code(c) => Some(c), PhysicalKey::Unidentified(_) => None, @@ -1014,8 +1099,10 @@ impl App { self.input.pending_text.push_str(&filtered); } } - let suppress_camera = - dim_flow_active(&state.mode) || state.focus.focused().is_some(); + let suppress_camera = dim_flow_active(&state.mode) || state.focus.focused().is_some(); + if matches!(named, Some(NamedKey::Escape)) && state.notification.is_some() { + state.notification = None; + } if let Some(code) = physical_code { match keyboard_action(code, &self.input, state) { Some(KeyAction::Exit) => event_loop.exit(), @@ -1034,10 +1121,30 @@ impl App { render_frame(state, scheduler, &mut self.input); if state.pending_exit { event_loop.exit(); + return; + } + if let Some(deadline) = next_wake_deadline(state, &self.input) { + event_loop.set_control_flow(ControlFlow::WaitUntil(deadline)); + } else { + event_loop.set_control_flow(ControlFlow::Wait); } } } +fn next_wake_deadline(state: &RenderState, input: &InputState) -> Option { + let now = std::time::Instant::now(); + let native_poll = state + .native_picker + .is_some() + .then(|| now + std::time::Duration::from_millis(40)); + let rename_deadline = state.shell.state.feature_tree.pending_rename.map(|pending| { + let window = bone_ui::input::DoubleClickWindow::DEFAULT.duration(); + let slack = std::time::Duration::from_millis(8); + input.start + pending.at.duration() + window + slack + }); + [native_poll, rename_deadline].into_iter().flatten().min() +} + #[allow( clippy::cast_precision_loss, reason = "viewport pixel counts at any realistic display size fit f32 mantissa" @@ -1051,6 +1158,7 @@ fn render_frame( scheduler: &mut redraw::Scheduler, input_state: &mut InputState, ) { + poll_native_picker(state); let extent = state.surface.extent(); let layout_size = layout_size_from_extent(extent); let theme = Arc::clone(&state.theme); @@ -1062,7 +1170,16 @@ fn render_frame( .cursor_px .filter(|c| state.viewport_rect.contains(physical_to_layout_pos(*c))) .and_then(|c| cursor_to_world(state.camera, c)); - let (mut frame, hotkey_actions, dim_outcome, conflict_outcome) = run_frame_ui( + let FrameOutcomes { + mut frame, + hotkey_actions, + dim: dim_outcome, + dim_conflict: conflict_outcome, + picker: picker_outcome, + overwrite: overwrite_outcome, + discard: discard_outcome, + notification: notification_outcome, + } = run_frame_ui( state, theme, &mut input, @@ -1076,7 +1193,17 @@ fn render_frame( &mut frame.overlay_paints, dim_outcome.as_ref(), conflict_outcome.as_ref(), + picker_outcome.as_ref(), + overwrite_outcome.as_ref(), + discard_outcome.as_ref(), + notification_outcome.as_ref(), ); + if let Some(cmd) = picker_outcome.and_then(|o| o.command) { + apply_picker_command(state, cmd); + } + apply_overwrite_outcome(state, overwrite_outcome); + apply_discard_outcome(state, discard_outcome); + apply_notification_outcome(state, notification_outcome); let claimed_pointer = dim_outcome.as_ref().is_some_and(|o| o.claimed_pointer); let frame = if claimed_pointer { suppress_pointer_activations(frame) @@ -1289,10 +1416,18 @@ fn suppress_pointer_activations(frame: shell::ShellFrame) -> shell::ShellFrame { } } +#[allow( + clippy::too_many_arguments, + reason = "popup overlay dispatch threads every transient surface" +)] fn apply_popup_overlays( overlay: &mut Vec, dim_outcome: Option<&DimensionEditorOutcome>, conflict_outcome: Option<&DimConflictOutcome>, + picker_outcome: Option<&file_menu::PickerModalOutcome>, + overwrite_outcome: Option<&OverwriteOutcome>, + discard_outcome: Option<&DiscardOutcome>, + notification_outcome: Option<&NotificationOutcome>, ) -> Option { let dim_closing = matches!( dim_outcome.map(|o| &o.action), @@ -1312,6 +1447,33 @@ fn apply_popup_overlays( conflict_outcome.map(|o| o.paints.as_slice()), conflict_closing, ); + let picker_closing = picker_outcome.and_then(|o| o.command.as_ref()).is_some(); + extend_when_open( + overlay, + picker_outcome.map(|o| o.paints.as_slice()), + picker_closing, + ); + let overwrite_closing = matches!( + overwrite_outcome.map(|o| o.action), + Some(OverwriteAction::Replace | OverwriteAction::Cancel), + ); + extend_when_open( + overlay, + overwrite_outcome.map(|o| o.paints.as_slice()), + overwrite_closing, + ); + let discard_closing = matches!( + discard_outcome.map(|o| o.action), + Some(DiscardAction::Confirm | DiscardAction::Cancel), + ); + extend_when_open( + overlay, + discard_outcome.map(|o| o.paints.as_slice()), + discard_closing, + ); + if let Some(notification) = notification_outcome { + overlay.extend(notification.paints.iter().cloned()); + } if dim_closing { None } else { @@ -1331,6 +1493,17 @@ fn extend_when_open( } } +struct FrameOutcomes { + frame: shell::ShellFrame, + hotkey_actions: Vec, + dim: Option, + dim_conflict: Option, + picker: Option, + overwrite: Option, + discard: Option, + notification: Option, +} + #[allow( clippy::too_many_arguments, reason = "run_frame_ui threads every per-frame UI subsystem" @@ -1344,12 +1517,7 @@ fn run_frame_ui( scopes: &HotkeyScopes, layout_size: LayoutSize, cursor_world: Option, -) -> ( - shell::ShellFrame, - Vec, - Option, - Option, -) { +) -> FrameOutcomes { let mut ctx = FrameCtx::new( theme, input, @@ -1390,12 +1558,42 @@ fn run_frame_ui( }); let conflict_outcome = dim_conflict_pending(&state.mode).map(|_| render_dim_conflict_modal(&mut ctx, layout_size)); - let actions = if conflict_outcome.is_some() || dim_outcome.is_some() { + let picker_outcome = state + .file_picker + .as_mut() + .map(|session| file_menu::render(&mut ctx, session, layout_size)); + let overwrite_outcome = state + .pending_overwrite + .is_some() + .then(|| render_overwrite_modal(&mut ctx, layout_size)); + let discard_outcome = state + .pending_discard + .is_some() + .then(|| render_discard_modal(&mut ctx, layout_size)); + let notification_outcome = state + .notification + .as_ref() + .map(|notification| render_notification_toast(&mut ctx, layout_size, notification)); + let any_modal_open = conflict_outcome.is_some() + || dim_outcome.is_some() + || picker_outcome.is_some() + || overwrite_outcome.is_some() + || discard_outcome.is_some(); + let actions = if any_modal_open { Vec::new() } else { ctx.dispatch_hotkeys(scopes) }; - (frame, actions, dim_outcome, conflict_outcome) + FrameOutcomes { + frame, + hotkey_actions: actions, + dim: dim_outcome, + dim_conflict: conflict_outcome, + picker: picker_outcome, + overwrite: overwrite_outcome, + discard: discard_outcome, + notification: notification_outcome, + } } fn dim_conflict_pending(mode: &Mode) -> Option { @@ -1454,6 +1652,232 @@ fn render_dim_conflict_modal( } } +#[derive(Clone, Debug, PartialEq)] +struct OverwriteOutcome { + paints: Vec, + action: OverwriteAction, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum OverwriteAction { + Idle, + Replace, + Cancel, +} + +fn render_overwrite_modal(ctx: &mut FrameCtx<'_>, layout_size: LayoutSize) -> OverwriteOutcome { + use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; + use bone_ui::{WidgetId, WidgetKey}; + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); + let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0)); + let id = WidgetId::ROOT.child(WidgetKey::new("file.overwrite")); + let response = show_confirmation( + ctx, + ConfirmationDialog { + id, + viewport, + size: dialog_size, + title: strings::FILE_OVERWRITE_TITLE, + message: strings::FILE_OVERWRITE_MESSAGE, + confirm_label: strings::FILE_OVERWRITE_REPLACE, + cancel_label: strings::FILE_OVERWRITE_CANCEL, + destructive: true, + }, + ); + let action = match response.outcome { + Some(ConfirmationOutcome::Confirm) => OverwriteAction::Replace, + Some(ConfirmationOutcome::Cancel) => OverwriteAction::Cancel, + None => OverwriteAction::Idle, + }; + OverwriteOutcome { + paints: response.paint, + action, + } +} + +#[derive(Clone, Debug, PartialEq)] +struct DiscardOutcome { + paints: Vec, + action: DiscardAction, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum DiscardAction { + Idle, + Confirm, + Cancel, +} + +fn render_discard_modal(ctx: &mut FrameCtx<'_>, layout_size: LayoutSize) -> DiscardOutcome { + use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; + use bone_ui::{WidgetId, WidgetKey}; + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); + let dialog_size = LayoutSize::new(LayoutPx::new(460.0), LayoutPx::new(190.0)); + let id = WidgetId::ROOT.child(WidgetKey::new("file.discard")); + let response = show_confirmation( + ctx, + ConfirmationDialog { + id, + viewport, + size: dialog_size, + title: strings::FILE_DISCARD_TITLE, + message: strings::FILE_DISCARD_MESSAGE, + confirm_label: strings::FILE_DISCARD_CONFIRM, + cancel_label: strings::FILE_DISCARD_CANCEL, + destructive: true, + }, + ); + let action = match response.outcome { + Some(ConfirmationOutcome::Confirm) => DiscardAction::Confirm, + Some(ConfirmationOutcome::Cancel) => DiscardAction::Cancel, + None => DiscardAction::Idle, + }; + DiscardOutcome { + paints: response.paint, + action, + } +} + +fn apply_discard_outcome(state: &mut RenderState, outcome: Option) { + let Some(outcome) = outcome else { return }; + match outcome.action { + DiscardAction::Idle => {} + DiscardAction::Cancel => { + state.pending_discard = None; + } + DiscardAction::Confirm => { + let Some(pending) = state.pending_discard.take() else { + return; + }; + match pending { + PendingDiscard::New => apply_new_document(state), + PendingDiscard::Open(path) => apply_open_folder(state, path), + } + } + } +} + +#[derive(Clone, Debug, PartialEq)] +struct NotificationOutcome { + paints: Vec, + dismissed: bool, +} + +fn render_notification_toast( + ctx: &mut FrameCtx<'_>, + layout_size: LayoutSize, + notification: &Notification, +) -> NotificationOutcome { + use bone_ui::widgets::{Button, ButtonVariant, WidgetPaint, show_button}; + use bone_ui::{WidgetId, WidgetKey}; + let theme = ctx.theme(); + let bg = match notification.kind { + NotificationKind::Info => theme.colors.surface(bone_ui::theme::SurfaceLevel::L2), + NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::SUBTLE_BG), + }; + let fg = match notification.kind { + NotificationKind::Info => theme.colors.text_primary(), + NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::TEXT_HIGH), + }; + let toast_width = layout_size.width.value().clamp(280.0, 420.0); + let toast_height = if notification.detail.is_some() { + 72.0 + } else { + 44.0 + }; + let margin = 24.0; + let toast_rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(margin), + LayoutPx::new(layout_size.height.value() - toast_height - margin), + ), + LayoutSize::new(LayoutPx::new(toast_width), LayoutPx::new(toast_height)), + ); + let id = WidgetId::ROOT.child(WidgetKey::new("notification.toast")); + let mut paints = vec![WidgetPaint::Surface { + rect: toast_rect, + fill: bg, + border: Some(bone_ui::theme::Border { + width: bone_ui::theme::StrokeWidth::HAIRLINE, + color: theme.colors.neutral.step(bone_ui::theme::Step12::BORDER), + }), + radius: theme.radius.sm, + elevation: Some(theme.elevation.level1), + }]; + let headline_rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(toast_rect.origin.x.value() + 16.0), + LayoutPx::new(toast_rect.origin.y.value() + 12.0), + ), + LayoutSize::new(LayoutPx::new(toast_width - 120.0), LayoutPx::new(20.0)), + ); + paints.push(WidgetPaint::Label { + rect: headline_rect, + text: bone_ui::widgets::LabelText::Key(notification.headline), + color: fg, + role: theme.typography.label, + }); + if let Some(detail) = ¬ification.detail { + let detail_rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(toast_rect.origin.x.value() + 16.0), + LayoutPx::new(toast_rect.origin.y.value() + 36.0), + ), + LayoutSize::new(LayoutPx::new(toast_width - 32.0), LayoutPx::new(24.0)), + ); + paints.push(WidgetPaint::Label { + rect: detail_rect, + text: bone_ui::widgets::LabelText::Owned(detail.clone()), + color: theme.colors.text_secondary(), + role: theme.typography.caption, + }); + } + let dismiss_rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new(toast_rect.origin.x.value() + toast_width - 96.0), + LayoutPx::new(toast_rect.origin.y.value() + 8.0), + ), + LayoutSize::new(LayoutPx::new(84.0), LayoutPx::new(28.0)), + ); + let dismiss_id = id.child(WidgetKey::new("dismiss")); + let response = show_button( + ctx, + Button::new( + dismiss_id, + dismiss_rect, + strings::NOTIFY_DISMISS, + ButtonVariant::Secondary, + ), + ); + paints.extend(response.paint); + NotificationOutcome { + paints, + dismissed: response.activated, + } +} + +fn apply_notification_outcome(state: &mut RenderState, outcome: Option) { + let Some(outcome) = outcome else { return }; + if outcome.dismissed { + state.notification = None; + } +} + +fn apply_overwrite_outcome(state: &mut RenderState, outcome: Option) { + let Some(outcome) = outcome else { return }; + match outcome.action { + OverwriteAction::Idle => {} + OverwriteAction::Cancel => { + state.pending_overwrite = None; + } + OverwriteAction::Replace => { + if let Some(folder) = state.pending_overwrite.take() { + perform_save_to(state, folder); + } + } + } +} + fn pending_dim(mode: &Mode) -> Option { match mode { Mode::Sketch { session, .. } => match session.dim_flow { @@ -1583,7 +2007,10 @@ fn confirm_dim_conflict_make_driven(state: &mut RenderState, pending: PendingDim let (after_add, dim_id) = match sketch.apply(SketchEdit::AddDimension(driven_proto)) { Ok((next, EditOutcome::Dimension(id))) => (next, id), Ok(_) => { - tracing::warn!(?driven_proto, "add driven dimension produced unexpected outcome"); + tracing::warn!( + ?driven_proto, + "add driven dimension produced unexpected outcome" + ); state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); return; } @@ -1676,11 +2103,318 @@ fn apply_menu_action(state: &mut RenderState, action: Option) Some(shell::MenuAction::OpenSettings) => { state.shell.state.settings_dialog_open = true; } + Some(shell::MenuAction::NewDocument) => { + request_new_document(state); + } + Some(shell::MenuAction::OpenDocument) => { + open_picker(state, bone_ui::widgets::FilePickerMode::Open, None); + } + Some(shell::MenuAction::SaveDocument) => { + apply_save_in_place(state); + } + Some(shell::MenuAction::SaveDocumentAs) => { + let seed = state.document.name().to_owned(); + open_picker(state, bone_ui::widgets::FilePickerMode::Save, Some(seed)); + } Some(shell::MenuAction::Undo | shell::MenuAction::Redo | shell::MenuAction::ExitSketch) | None => {} } } +fn request_new_document(state: &mut RenderState) { + if is_dirty(state) { + state.pending_discard = Some(PendingDiscard::New); + } else { + apply_new_document(state); + } +} + +fn request_open_folder(state: &mut RenderState, path: PathBuf) { + if is_dirty(state) { + state.pending_discard = Some(PendingDiscard::Open(path)); + } else { + apply_open_folder(state, path); + } +} + +fn is_dirty(state: &RenderState) -> bool { + state.last_saved.as_ref() != Some(&state.document) +} + +fn apply_new_document(state: &mut RenderState) { + let sketch = default_sketch(); + let scene = match SketchScene::extract(&sketch) { + Ok(s) => s, + Err(e) => { + tracing::warn!(error = %e, "scene extract failed on new document"); + notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); + return; + } + }; + let (document, sketch_id) = initial_document(sketch); + state.last_saved = Some(document.clone()); + state.document = document; + state.plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); + state.scene = scene; + state.mode = Mode::Idle; + state.selection = Selection::default(); + state.current_folder = None; + state.pending_overwrite = None; + let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { + unreachable!("UNDO_CAPACITY constant is non-zero"); + }; + state.undo = UndoStack::with_capacity(undo_capacity); + state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); +} + +fn open_picker( + state: &mut RenderState, + mode: bone_ui::widgets::FilePickerMode, + seed_filename: Option, +) { + if state.file_picker.is_some() || state.native_picker.is_some() { + return; + } + let starting_folder = state + .current_folder + .as_ref() + .map(|f| f.path().to_owned()) + .or_else(|| { + state + .documents_root + .is_dir() + .then(|| state.documents_root.clone()) + }); + let title_key = match mode { + bone_ui::widgets::FilePickerMode::Open => strings::FILE_PICKER_TITLE_OPEN, + bone_ui::widgets::FilePickerMode::Save => strings::FILE_PICKER_TITLE_SAVE_AS, + }; + let accept_key = match mode { + bone_ui::widgets::FilePickerMode::Open => strings::FILE_PICKER_OPEN, + bone_ui::widgets::FilePickerMode::Save => strings::FILE_PICKER_SAVE, + }; + let title = state.strings.resolve(title_key).to_owned(); + let accept_label = state.strings.resolve(accept_key).to_owned(); + let native_req = native_picker::Request { + mode, + title: title.as_str(), + accept_label: accept_label.as_str(), + seed_filename: seed_filename.as_deref(), + current_folder: starting_folder.as_deref(), + }; + match native_picker::spawn(native_req) { + Ok(handle) => { + state.native_picker = Some(handle); + return; + } + Err(native_picker::SpawnError::Unsupported) => { + tracing::debug!("native picker unavailable, falling back to custom picker"); + } + } + open_custom_picker(state, mode, seed_filename); +} + +fn open_custom_picker( + state: &mut RenderState, + mode: bone_ui::widgets::FilePickerMode, + seed_filename: Option, +) { + let entries = match file_menu::scan_document_folders(&state.documents_root) { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, path = %state.documents_root.display(), "scan documents root failed"); + notify_error(state, strings::NOTIFY_SCAN_FAILED, e.to_string()); + Vec::new() + } + }; + state.file_picker = Some(file_menu::FilePickerSession::open( + state.documents_root.clone(), + mode, + seed_filename, + entries, + )); +} + +fn poll_native_picker(state: &mut RenderState) { + let Some(handle) = state.native_picker.as_ref() else { + return; + }; + let outcome = match handle.poll() { + native_picker::PollState::Pending => return, + native_picker::PollState::Ready(o) => o, + }; + let mode = handle.mode; + state.native_picker = None; + match outcome { + native_picker::NativeOutcome::Path(path) => match mode { + bone_ui::widgets::FilePickerMode::Open => request_open_folder(state, path), + bone_ui::widgets::FilePickerMode::Save => apply_save_as(state, path), + }, + native_picker::NativeOutcome::Cancelled => {} + native_picker::NativeOutcome::Error(message) => { + tracing::warn!(error = %message, "native picker errored, falling back to custom picker"); + let seed = matches!(mode, bone_ui::widgets::FilePickerMode::Save) + .then(|| state.document.name().to_owned()); + open_custom_picker(state, mode, seed); + } + } +} + +fn apply_save_in_place(state: &mut RenderState) { + let Some(folder) = state.current_folder.clone() else { + let seed = state.document.name().to_owned(); + open_picker(state, bone_ui::widgets::FilePickerMode::Save, Some(seed)); + return; + }; + if let Err(e) = bone_document::save(&state.document, &folder) { + tracing::warn!(error = %e, path = %folder.path().display(), "save failed"); + notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string()); + return; + } + state.last_saved = Some(state.document.clone()); + notify_info(state, strings::NOTIFY_SAVED, None); +} + +fn apply_picker_command(state: &mut RenderState, command: file_menu::PickerCommand) { + state.file_picker = None; + match command { + file_menu::PickerCommand::Cancel => {} + file_menu::PickerCommand::OpenFolder(path) => request_open_folder(state, path), + file_menu::PickerCommand::SaveAs(path) => apply_save_as(state, path), + } +} + +fn apply_open_folder(state: &mut RenderState, path: PathBuf) { + let folder = DocumentFolder::new(path); + let document = match bone_document::load(&folder) { + Ok(d) => d, + Err(e) => { + tracing::warn!(error = %e, path = %folder.path().display(), "load failed"); + notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); + return; + } + }; + install_loaded_document(state, document, Some(folder)); +} + +fn apply_save_as(state: &mut RenderState, path: PathBuf) { + let folder = DocumentFolder::new(path); + let in_place = state + .current_folder + .as_ref() + .is_some_and(|current| same_folder(current.path(), folder.path())); + if folder.document_file().is_file() && !in_place { + state.pending_overwrite = Some(folder); + return; + } + perform_save_to(state, folder); +} + +fn perform_save_to(state: &mut RenderState, folder: DocumentFolder) { + let prior_name = state.document.name().to_owned(); + state + .document + .set_name(folder_display_name(&folder, &state.strings)); + if let Err(e) = bone_document::save(&state.document, &folder) { + tracing::warn!(error = %e, path = %folder.path().display(), "save as failed"); + state.document.set_name(prior_name); + notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string()); + return; + } + state.current_folder = Some(folder); + state.last_saved = Some(state.document.clone()); + notify_info(state, strings::NOTIFY_SAVED, None); +} + +fn folder_display_name(folder: &DocumentFolder, string_table: &StringTable) -> String { + folder.path().file_name().map_or_else( + || { + string_table + .resolve(strings::DEFAULT_DOCUMENT_NAME) + .to_owned() + }, + |s| s.to_string_lossy().into_owned(), + ) +} + +fn same_folder(a: &Path, b: &Path) -> bool { + match (resolve_path(a), resolve_path(b)) { + (Some(x), Some(y)) => x == y, + _ => false, + } +} + +fn resolve_path(path: &Path) -> Option { + if let Ok(canon) = std::fs::canonicalize(path) { + return Some(canon); + } + let parent = path.parent()?; + let file_name = path.file_name()?; + let parent_canon = std::fs::canonicalize(parent).ok()?; + Some(parent_canon.join(file_name)) +} + +fn notify_error(state: &mut RenderState, headline: bone_ui::strings::StringKey, detail: String) { + state.notification = Some(Notification { + kind: NotificationKind::Error, + headline, + detail: Some(detail), + }); +} + +fn notify_info( + state: &mut RenderState, + headline: bone_ui::strings::StringKey, + detail: Option, +) { + state.notification = Some(Notification { + kind: NotificationKind::Info, + headline, + detail, + }); +} + +fn install_loaded_document( + state: &mut RenderState, + document: Document, + folder: Option, +) { + let plane_sketches = plane_sketches_from(&document); + let active_sketch_id = plane_sketches.get(&Plane::Xy).copied(); + state.last_saved = Some(document.clone()); + state.document = document; + state.plane_sketches = plane_sketches; + state.mode = Mode::Idle; + state.selection = Selection::default(); + state.current_folder = folder; + state.pending_overwrite = None; + let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { + unreachable!("UNDO_CAPACITY constant is non-zero"); + }; + state.undo = UndoStack::with_capacity(undo_capacity); + let scene_attempt = active_sketch_id + .and_then(|id| state.document.sketch(id)) + .map(SketchScene::extract); + state.scene = match scene_attempt { + None => SketchScene::empty(), + Some(Ok(scene)) => scene, + Some(Err(e)) => { + tracing::warn!(error = %e, "scene extract on load failed"); + notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); + SketchScene::empty() + } + }; + state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); +} + +fn plane_sketches_from(document: &Document) -> BTreeMap { + document + .sketches() + .map(|(id, _)| (Plane::Xy, id)) + .take(1) + .collect() +} + fn apply_settings_change(state: &mut RenderState, change: Option) { if let Some(next) = change { state.settings = next; @@ -2327,10 +3061,17 @@ mod tests { apply_sketch_rename_into( &mut document, &mut undo, - shell::SketchRenameRequest { id, label: "Profile".to_owned() }, + shell::SketchRenameRequest { + id, + label: "Profile".to_owned(), + }, ); assert_eq!(document.sketch_label(id), Some("Profile")); - assert_eq!(undo.past_len(), 1, "successful rename records one undo snapshot"); + assert_eq!( + undo.past_len(), + 1, + "successful rename records one undo snapshot" + ); } #[test] @@ -2340,7 +3081,10 @@ mod tests { apply_sketch_rename_into( &mut document, &mut undo, - shell::SketchRenameRequest { id, label: " ".to_owned() }, + shell::SketchRenameRequest { + id, + label: " ".to_owned(), + }, ); assert_eq!(document.sketch_label(id), Some("Sketch1")); assert_eq!(undo.past_len(), 0); @@ -2353,10 +3097,17 @@ mod tests { apply_sketch_rename_into( &mut document, &mut undo, - shell::SketchRenameRequest { id, label: " Sketch1 ".to_owned() }, + shell::SketchRenameRequest { + id, + label: " Sketch1 ".to_owned(), + }, ); assert_eq!(document.sketch_label(id), Some("Sketch1")); - assert_eq!(undo.past_len(), 0, "trimmed-equal rename must not record undo"); + assert_eq!( + undo.past_len(), + 0, + "trimmed-equal rename must not record undo" + ); } #[test] diff --git a/crates/bone-app/src/native_picker.rs b/crates/bone-app/src/native_picker.rs new file mode 100644 index 0000000..362289e --- /dev/null +++ b/crates/bone-app/src/native_picker.rs @@ -0,0 +1,213 @@ +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, TryRecvError}; + +use bone_ui::widgets::FilePickerMode; + +#[derive(Debug)] +pub enum NativeOutcome { + Path(PathBuf), + Cancelled, + Error(String), +} + +#[derive(Debug)] +pub enum SpawnError { + Unsupported, +} + +pub struct PendingHandle { + rx: Receiver, + pub mode: FilePickerMode, +} + +impl PendingHandle { + #[must_use] + pub fn poll(&self) -> PollState { + match self.rx.try_recv() { + Ok(outcome) => PollState::Ready(outcome), + Err(TryRecvError::Empty) => PollState::Pending, + Err(TryRecvError::Disconnected) => { + PollState::Ready(NativeOutcome::Error("picker worker disconnected".to_owned())) + } + } + } +} + +#[derive(Debug)] +pub enum PollState { + Pending, + Ready(NativeOutcome), +} + +#[derive(Copy, Clone)] +pub struct Request<'a> { + pub mode: FilePickerMode, + pub title: &'a str, + pub accept_label: &'a str, + pub seed_filename: Option<&'a str>, + pub current_folder: Option<&'a std::path::Path>, +} + +#[cfg(target_os = "linux")] +pub fn spawn(req: Request<'_>) -> Result { + linux_impl::spawn(req) +} + +#[cfg(not(target_os = "linux"))] +pub fn spawn(_req: Request<'_>) -> Result { + Err(SpawnError::Unsupported) +} + +#[cfg(target_os = "linux")] +mod linux_impl { + use std::path::PathBuf; + use std::sync::mpsc; + + use ashpd::desktop::file_chooser::{OpenFileRequest, SaveFileRequest}; + use bone_ui::widgets::FilePickerMode; + + use super::{NativeOutcome, PendingHandle, Request, SpawnError}; + + pub(super) fn spawn(req: Request<'_>) -> Result { + let mode = req.mode; + let title = req.title.to_owned(); + let accept = req.accept_label.to_owned(); + let seed = req.seed_filename.map(str::to_owned); + let folder = req.current_folder.map(PathBuf::from); + let (tx, rx) = mpsc::channel(); + let join = std::thread::Builder::new() + .name("bone-native-picker".to_owned()) + .spawn(move || { + let outcome = pollster::block_on(run(mode, &title, &accept, seed, folder)); + let _ = tx.send(outcome); + }); + match join { + Ok(_handle) => Ok(PendingHandle { rx, mode }), + Err(_) => Err(SpawnError::Unsupported), + } + } + + async fn run( + mode: FilePickerMode, + title: &str, + accept: &str, + seed: Option, + folder: Option, + ) -> NativeOutcome { + match mode { + FilePickerMode::Open => run_open(title, accept, folder).await, + FilePickerMode::Save => run_save(title, accept, seed, folder).await, + } + } + + async fn run_open( + title: &str, + accept: &str, + folder: Option, + ) -> NativeOutcome { + let mut req = OpenFileRequest::default() + .title(title) + .accept_label(accept) + .modal(true) + .multiple(false) + .directory(true); + if let Some(f) = folder { + req = match req.current_folder::(Some(f)) { + Ok(r) => r, + Err(e) => return NativeOutcome::Error(e.to_string()), + }; + } + let response = match req.send().await { + Ok(r) => r, + Err(e) => return classify_send_error(&e), + }; + let selected = match response.response() { + Ok(s) => s, + Err(e) => return classify_response_error(&e), + }; + match selected.uris().first().and_then(|u| uri_to_path(u.as_str())) { + Some(path) => NativeOutcome::Path(path), + None => NativeOutcome::Cancelled, + } + } + + async fn run_save( + title: &str, + accept: &str, + seed: Option, + folder: Option, + ) -> NativeOutcome { + let mut req = SaveFileRequest::default() + .title(title) + .accept_label(accept) + .modal(true); + if let Some(s) = seed.as_deref() { + req = req.current_name(s); + } + if let Some(f) = folder { + req = match req.current_folder::(Some(f)) { + Ok(r) => r, + Err(e) => return NativeOutcome::Error(e.to_string()), + }; + } + let response = match req.send().await { + Ok(r) => r, + Err(e) => return classify_send_error(&e), + }; + let selected = match response.response() { + Ok(s) => s, + Err(e) => return classify_response_error(&e), + }; + match selected.uris().first().and_then(|u| uri_to_path(u.as_str())) { + Some(path) => NativeOutcome::Path(path), + None => NativeOutcome::Cancelled, + } + } + + fn classify_send_error(e: &ashpd::Error) -> NativeOutcome { + NativeOutcome::Error(format!("portal send: {e}")) + } + + fn classify_response_error(e: &ashpd::Error) -> NativeOutcome { + match e { + ashpd::Error::Response(ashpd::desktop::ResponseError::Cancelled) => { + NativeOutcome::Cancelled + } + other => NativeOutcome::Error(format!("portal response: {other}")), + } + } + + fn uri_to_path(uri: &str) -> Option { + let encoded = uri.strip_prefix("file://")?; + let decoded = + percent_encoding::percent_decode_str(encoded).decode_utf8_lossy(); + Some(PathBuf::from(decoded.as_ref())) + } + + #[cfg(test)] + mod tests { + use super::uri_to_path; + use std::path::PathBuf; + + #[test] + fn plain_path() { + assert_eq!( + uri_to_path("file:///home/nel/docs"), + Some(PathBuf::from("/home/nel/docs")), + ); + } + + #[test] + fn percent_decoded() { + assert_eq!( + uri_to_path("file:///home/nel/my%20docs"), + Some(PathBuf::from("/home/nel/my docs")), + ); + } + + #[test] + fn rejects_non_file_scheme() { + assert_eq!(uri_to_path("http://example.com/docs"), None); + } + } +} diff --git a/crates/bone-app/src/shell.rs b/crates/bone-app/src/shell.rs index 948c74d..d73047e 100644 --- a/crates/bone-app/src/shell.rs +++ b/crates/bone-app/src/shell.rs @@ -9,14 +9,14 @@ use bone_types::{Length, Point2, SketchDimensionId, SketchEntityId, SketchId}; use bone_ui::a11y::{AccessNode, Role}; use bone_ui::frame::{FrameCtx, InteractDeclaration}; use bone_ui::hit_test::Sense; -use bone_ui::widgets::GlyphMark; use bone_ui::layout::{ - Axis, DockNode, DockPanel, DockState, GridChild, GridLine, GridSpan, GridTrack, - Layout, LayoutPos, LayoutPx, LayoutRect, LayoutSize, NodeKind, PanelId, RetainedLayout, - SolvedLayout, SolvedNode, SplitFraction, TrackSize, measure, + Axis, DockNode, DockPanel, DockState, GridChild, GridLine, GridSpan, GridTrack, Layout, + LayoutPos, LayoutPx, LayoutRect, LayoutSize, NodeKind, PanelId, RetainedLayout, SolvedLayout, + SolvedNode, SplitFraction, TrackSize, measure, }; use bone_ui::strings::{StringKey, StringTable}; use bone_ui::theme::{Border, ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme}; +use bone_ui::widgets::GlyphMark; use bone_ui::widgets::{ AngleEditor, Clipboard, Dialog, DialogButton, LabelText, LengthEditor, MemoryClipboard, MenuBar, MenuBarEntry, MenuBarState, MenuItem, PropertyCell, PropertyEditor, PropertyGrid, @@ -93,6 +93,10 @@ struct ShellIds { menu_sketch: WidgetId, menu_window: WidgetId, menu_help: WidgetId, + menu_file_new: WidgetId, + menu_file_open: WidgetId, + menu_file_save: WidgetId, + menu_file_save_as: WidgetId, menu_file_quit: WidgetId, menu_edit_undo: WidgetId, menu_edit_redo: WidgetId, @@ -151,6 +155,10 @@ impl ShellIds { menu_sketch, menu_window: menu_bar.child(WidgetKey::new("window")), menu_help: menu_bar.child(WidgetKey::new("help")), + menu_file_new: menu_file.child(WidgetKey::new("new")), + menu_file_open: menu_file.child(WidgetKey::new("open")), + menu_file_save: menu_file.child(WidgetKey::new("save")), + menu_file_save_as: menu_file.child(WidgetKey::new("save_as")), menu_file_quit: menu_file.child(WidgetKey::new("quit")), menu_edit_undo: menu_edit.child(WidgetKey::new("undo")), menu_edit_redo: menu_edit.child(WidgetKey::new("redo")), @@ -177,6 +185,10 @@ impl ShellIds { fn menu_action_for(&self, id: WidgetId) -> Option { [ + (self.menu_file_new, MenuAction::NewDocument), + (self.menu_file_open, MenuAction::OpenDocument), + (self.menu_file_save, MenuAction::SaveDocument), + (self.menu_file_save_as, MenuAction::SaveDocumentAs), (self.menu_file_quit, MenuAction::Quit), (self.menu_edit_undo, MenuAction::Undo), (self.menu_edit_redo, MenuAction::Redo), @@ -192,6 +204,10 @@ impl ShellIds { #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum MenuAction { + NewDocument, + OpenDocument, + SaveDocument, + SaveDocumentAs, Quit, Undo, Redo, @@ -303,15 +319,13 @@ impl Shell { ], gap, ); - chrome_grid( - ChromeRows { - menu: Layout::leaf(self.ids.menu_bar), - ribbon: Layout::leaf(self.ids.ribbon), - center, - doc_tabs: Layout::leaf(self.ids.doc_tabs), - status: Layout::leaf(self.ids.status_bar), - }, - ) + chrome_grid(ChromeRows { + menu: Layout::leaf(self.ids.menu_bar), + ribbon: Layout::leaf(self.ids.ribbon), + center, + doc_tabs: Layout::leaf(self.ids.doc_tabs), + status: Layout::leaf(self.ids.status_bar), + }) } #[must_use] @@ -362,7 +376,10 @@ impl Shell { let menu_bar_rect = leaf_rect(&solved, self.ids.menu_bar).unwrap_or_else(zero_rect); let left_pane_rect = panel_rect(&solved, self.panels.left_pane) .map_or_else(zero_rect, |r| inset_rect(r, inset_px)); - let LeftPaneSplit { tab_strip_rect, content_rect } = split_left_pane(left_pane_rect); + let LeftPaneSplit { + tab_strip_rect, + content_rect, + } = split_left_pane(left_pane_rect); let status_rect = leaf_rect(&solved, self.ids.status_bar).unwrap_or_else(zero_rect); let doc_tabs_rect = leaf_rect(&solved, self.ids.doc_tabs).unwrap_or_else(zero_rect); let mut popover_paints: Vec = Vec::new(); @@ -442,16 +459,10 @@ impl Shell { cursor_world, &mut paints, ); - let confirm = render_confirm_corner( - ctx, - viewport_rect, - &self.ids, - mode.is_sketch(), - &mut paints, - ); + let confirm = + render_confirm_corner(ctx, viewport_rect, &self.ids, mode.is_sketch(), &mut paints); let confirm_action = confirm; - let exit_sketch = confirm_action.is_some() - || menu_action == Some(MenuAction::ExitSketch); + let exit_sketch = confirm_action.is_some() || menu_action == Some(MenuAction::ExitSketch); let activated_tool = activated_widget.and_then(|id| self.tool_index.get(&id).copied()); let activated_relation = resolve_activated_relation( activated_widget, @@ -822,18 +833,14 @@ fn build_menu_entries(ids: &ShellIds, is_sketch: bool) -> Vec { id: file, label: strings::MENU_FILE, items: vec![ - action(file.child(WidgetKey::new("new")), strings::MENU_FILE_NEW, None, true), + action(ids.menu_file_new, strings::MENU_FILE_NEW, None, false), + action(ids.menu_file_open, strings::MENU_FILE_OPEN, None, false), + action(ids.menu_file_save, strings::MENU_FILE_SAVE, None, false), action( - file.child(WidgetKey::new("open")), - strings::MENU_FILE_OPEN, + ids.menu_file_save_as, + strings::MENU_FILE_SAVE_AS, None, - true, - ), - action( - file.child(WidgetKey::new("save")), - strings::MENU_FILE_SAVE, - None, - true, + false, ), MenuItem::Separator, action( @@ -1055,33 +1062,23 @@ fn render_feature_tree( let leaf = |key: &'static str, label: StringKey| { TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) }; - let feature_leaf = |key: &'static str, label: StringKey| { - leaf(key, label).with_glyph(GlyphMark::TreeFeature) - }; - let placeholder = |key: &'static str, label: StringKey| { - feature_leaf(key, label).disabled(true) - }; - let plane_leaf = |key: &'static str, label: StringKey| { - leaf(key, label).with_glyph(GlyphMark::TreePlane) - }; + let feature_leaf = + |key: &'static str, label: StringKey| leaf(key, label).with_glyph(GlyphMark::TreeFeature); + let placeholder = |key: &'static str, label: StringKey| feature_leaf(key, label).disabled(true); + let plane_leaf = + |key: &'static str, label: StringKey| leaf(key, label).with_glyph(GlyphMark::TreePlane); let sketch_rows: Vec<(SketchId, WidgetId, TreeNode)> = document .sketches() .map(|(sketch_id, _)| { let widget_id = sketch_widget_id(part_id, sketch_id); - let label = document - .sketch_label(sketch_id) - .unwrap_or("") - .to_owned(); - let node = TreeNode::leaf_owned(widget_id, label) - .with_glyph(GlyphMark::TreeSketch); + let label = document.sketch_label(sketch_id).unwrap_or("").to_owned(); + let node = TreeNode::leaf_owned(widget_id, label).with_glyph(GlyphMark::TreeSketch); (sketch_id, widget_id, node) }) .collect(); let renamable: Vec = sketch_rows.iter().map(|(_, w, _)| *w).collect(); - let widget_to_sketch: BTreeMap = sketch_rows - .iter() - .map(|(s, w, _)| (*w, *s)) - .collect(); + let widget_to_sketch: BTreeMap = + sketch_rows.iter().map(|(s, w, _)| (*w, *s)).collect(); let children: Vec = [ placeholder("history", strings::FEATURE_HISTORY), placeholder("sensors", strings::FEATURE_SENSORS), @@ -1113,7 +1110,10 @@ fn render_feature_tree( widget_to_sketch .get(&id) .copied() - .map(|sketch_id| SketchRenameRequest { id: sketch_id, label: text }) + .map(|sketch_id| SketchRenameRequest { + id: sketch_id, + label: text, + }) }); FeatureTreeOutcome { double_activated: response.double_activated, @@ -1470,9 +1470,7 @@ fn endpoint_or_entity_label( Some(SketchEntity::Line(_)) => strings_table .resolve(strings::PROPERTY_KIND_LINE) .to_owned(), - Some(SketchEntity::Arc(_)) => strings_table - .resolve(strings::PROPERTY_KIND_ARC) - .to_owned(), + Some(SketchEntity::Arc(_)) => strings_table.resolve(strings::PROPERTY_KIND_ARC).to_owned(), Some(SketchEntity::Circle(_)) => strings_table .resolve(strings::PROPERTY_KIND_CIRCLE) .to_owned(), @@ -1636,7 +1634,11 @@ fn render_doc_tabs( rect.origin, LayoutSize::new(LayoutPx::new(DOC_TAB_WIDTH_PX), rect.size.height), ); - let tabs = [Tab::new(ids.doc_tab_model, tab_rect, strings::DOC_TAB_MODEL)]; + let tabs = [Tab::new( + ids.doc_tab_model, + tab_rect, + strings::DOC_TAB_MODEL, + )]; let response = show_tabs( ctx, Tabs::new( @@ -2029,10 +2031,8 @@ struct LeftPaneSplit { fn split_left_pane(rect: LayoutRect) -> LeftPaneSplit { let strip_height = LayoutPx::new(LEFT_PANE_TAB_STRIP_HEIGHT.min(rect.size.height.value())); - let tab_strip_rect = LayoutRect::new( - rect.origin, - LayoutSize::new(rect.size.width, strip_height), - ); + let tab_strip_rect = + LayoutRect::new(rect.origin, LayoutSize::new(rect.size.width, strip_height)); let content_rect = LayoutRect::new( LayoutPos::new( rect.origin.x, @@ -2185,11 +2185,17 @@ fn render_confirm_corner( let accept_x = cancel_x - CONFIRM_BUTTON_GAP - CONFIRM_BUTTON_PX; let accept_rect = LayoutRect::new( LayoutPos::new(LayoutPx::new(accept_x), LayoutPx::new(top_y)), - LayoutSize::new(LayoutPx::new(CONFIRM_BUTTON_PX), LayoutPx::new(CONFIRM_BUTTON_PX)), + LayoutSize::new( + LayoutPx::new(CONFIRM_BUTTON_PX), + LayoutPx::new(CONFIRM_BUTTON_PX), + ), ); let cancel_rect = LayoutRect::new( LayoutPos::new(LayoutPx::new(cancel_x), LayoutPx::new(top_y)), - LayoutSize::new(LayoutPx::new(CONFIRM_BUTTON_PX), LayoutPx::new(CONFIRM_BUTTON_PX)), + LayoutSize::new( + LayoutPx::new(CONFIRM_BUTTON_PX), + LayoutPx::new(CONFIRM_BUTTON_PX), + ), ); let accept_clicked = paint_confirm_button( ctx, @@ -2244,7 +2250,10 @@ fn paint_confirm_button( ConfirmTone::Cancel => theme.colors.danger, }; let (fill, glyph_color) = if interaction.pressed() { - (palette.step(Step12::SELECTED_BG), palette.step(Step12::HOVER_SOLID)) + ( + palette.step(Step12::SELECTED_BG), + palette.step(Step12::HOVER_SOLID), + ) } else if interaction.hover() { (palette.step(Step12::HOVER_BG), palette.step(Step12::SOLID)) } else { @@ -2343,7 +2352,15 @@ mod tests { &mut a11y, &mut shaper, ); - shell.render(&mut ctx, document, mode, selection, Settings::default(), size, None) + shell.render( + &mut ctx, + document, + mode, + selection, + Settings::default(), + size, + None, + ) } #[test] @@ -2355,6 +2372,56 @@ mod tests { ); } + #[test] + fn file_menu_ids_map_to_file_actions() { + let shell = Shell::new(); + assert_eq!( + shell.ids.menu_action_for(shell.ids.menu_file_new), + Some(MenuAction::NewDocument), + ); + assert_eq!( + shell.ids.menu_action_for(shell.ids.menu_file_open), + Some(MenuAction::OpenDocument), + ); + assert_eq!( + shell.ids.menu_action_for(shell.ids.menu_file_save), + Some(MenuAction::SaveDocument), + ); + assert_eq!( + shell.ids.menu_action_for(shell.ids.menu_file_save_as), + Some(MenuAction::SaveDocumentAs), + ); + } + + #[test] + fn file_menu_actions_are_enabled() { + let entries = build_menu_entries(&ShellIds::standard(), false); + let Some(file_menu) = entries.iter().find(|e| e.label == strings::MENU_FILE) else { + panic!("file menu entry missing"); + }; + let actions: Vec<(StringKey, bool)> = file_menu + .items + .iter() + .filter_map(|i| match i { + MenuItem::Action { + label, disabled, .. + } => Some((*label, *disabled)), + _ => None, + }) + .collect(); + let entry_for = |key: StringKey| { + actions + .iter() + .find(|(l, _)| *l == key) + .copied() + .unwrap_or((key, true)) + }; + assert!(!entry_for(strings::MENU_FILE_NEW).1); + assert!(!entry_for(strings::MENU_FILE_OPEN).1); + assert!(!entry_for(strings::MENU_FILE_SAVE).1); + assert!(!entry_for(strings::MENU_FILE_SAVE_AS).1); + } + #[test] fn settings_dialog_does_not_render_when_closed() { let frame = render_with( @@ -2441,7 +2508,10 @@ mod tests { assert!(v.size.height.value() > 0.0); assert!(v.min_x().value() > 0.0, "left pane carved on left"); assert!(v.min_y().value() > 0.0, "ribbon carved on top"); - assert!(v.max_x().value() <= 1280.0, "viewport bounded by window width"); + assert!( + v.max_x().value() <= 1280.0, + "viewport bounded by window width" + ); assert!(v.max_y().value() < 800.0, "status bar carved on bottom"); } @@ -2859,9 +2929,7 @@ mod tests { (sketch, line) } - fn sketch_with_dim( - kind: DimensionKind, - ) -> (bone_document::Sketch, SketchDimensionId) { + fn sketch_with_dim(kind: DimensionKind) -> (bone_document::Sketch, SketchDimensionId) { use bone_document::{EditOutcome, SketchEdit}; use bone_types::Point2; use uom::si::length::millimeter; @@ -2880,7 +2948,11 @@ mod tests { (s, id) } - fn sketch_with_relation() -> (bone_document::Sketch, bone_types::SketchRelationId, SketchEntityId) { + fn sketch_with_relation() -> ( + bone_document::Sketch, + bone_types::SketchRelationId, + SketchEntityId, + ) { use bone_document::{EditOutcome, SketchEdit}; use bone_types::Point2; let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); @@ -2915,8 +2987,7 @@ mod tests { &Mode::enter_sketch(sketch_id), &Selection::Dimension(dim_id), ); - let Some(DimPropertyEditor::Length { id, editor, .. }) = &shell.state.dim_property - else { + let Some(DimPropertyEditor::Length { id, editor, .. }) = &shell.state.dim_property else { panic!("expected length editor populated"); }; assert_eq!(*id, dim_id); @@ -3046,13 +3117,17 @@ mod tests { &Selection::Relation(rel_id), ); let any_horizontal_label = frame.paints.iter().any(|p| match p { - WidgetPaint::Label { text: LabelText::Owned(text), .. } => { - text == StringTable::empty().resolve(strings::TOOL_HORIZONTAL) - } + WidgetPaint::Label { + text: LabelText::Owned(text), + .. + } => text == StringTable::empty().resolve(strings::TOOL_HORIZONTAL), _ => false, }); assert!(any_horizontal_label, "relation kind label should appear"); - assert!(shell.state.dim_property.is_none(), "relation does not own dim editor"); + assert!( + shell.state.dim_property.is_none(), + "relation does not own dim editor" + ); } fn shell_drive( @@ -3242,7 +3317,10 @@ mod tests { #[test] fn click_on_sketch_row_then_f2_enters_rename_via_full_shell() { - use bone_ui::input::{KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, PointerButtonMask, PointerSample}; + use bone_ui::input::{ + KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, PointerButtonMask, + PointerSample, + }; let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); let (document, sketch_id) = document_with_sketch(sketch); let mut shell = Shell::new(); @@ -3276,16 +3354,40 @@ mod tests { let mut press = InputSnapshot::idle(FrameInstant::ZERO); press.pointer = Some(PointerSample::new(center)); press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); - let _ = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, press); + let _ = drive_with_snap( + &mut shell, + &document, + &Mode::Idle, + &Selection::default(), + &mut focus, + &mut prev, + press, + ); let mut release = InputSnapshot::idle(FrameInstant::ZERO); release.pointer = Some(PointerSample::new(center)); release.buttons_released = PointerButtonMask::just(PointerButton::Primary); - let _ = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, release); + let _ = drive_with_snap( + &mut shell, + &document, + &Mode::Idle, + &Selection::default(), + &mut focus, + &mut prev, + release, + ); let mut idle = InputSnapshot::idle(FrameInstant::ZERO); idle.pointer = Some(PointerSample::new(center)); - let _ = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, idle); + let _ = drive_with_snap( + &mut shell, + &document, + &Mode::Idle, + &Selection::default(), + &mut focus, + &mut prev, + idle, + ); assert_eq!( focus.focused(), @@ -3295,8 +3397,19 @@ mod tests { let mut f2 = InputSnapshot::idle(FrameInstant::ZERO); f2.pointer = Some(PointerSample::new(center)); - f2.keys_pressed.push(KeyEvent::new(KeyCode::Named(NamedKey::F2), ModifierMask::NONE)); - let _ = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, f2); + f2.keys_pressed.push(KeyEvent::new( + KeyCode::Named(NamedKey::F2), + ModifierMask::NONE, + )); + let _ = drive_with_snap( + &mut shell, + &document, + &Mode::Idle, + &Selection::default(), + &mut focus, + &mut prev, + f2, + ); assert_eq!( shell.state.feature_tree.renaming, Some(widget), @@ -3337,24 +3450,46 @@ mod tests { LayoutPx::new(row_rect.origin.y.value() + row_rect.size.height.value() / 2.0), ); - let click = |shell: &mut Shell, - focus: &mut FocusManager, - prev: &mut HitState| { + let click = |shell: &mut Shell, focus: &mut FocusManager, prev: &mut HitState| { let mut press = InputSnapshot::idle(FrameInstant::ZERO); press.pointer = Some(PointerSample::new(center)); press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); - let _ = drive_with_snap(shell, &document, &Mode::Idle, &Selection::default(), focus, prev, press); + let _ = drive_with_snap( + shell, + &document, + &Mode::Idle, + &Selection::default(), + focus, + prev, + press, + ); let mut release = InputSnapshot::idle(FrameInstant::ZERO); release.pointer = Some(PointerSample::new(center)); release.buttons_released = PointerButtonMask::just(PointerButton::Primary); - drive_with_snap(shell, &document, &Mode::Idle, &Selection::default(), focus, prev, release) + drive_with_snap( + shell, + &document, + &Mode::Idle, + &Selection::default(), + focus, + prev, + release, + ) }; let _ = click(&mut shell, &mut focus, &mut prev); let _ = click(&mut shell, &mut focus, &mut prev); let mut idle = InputSnapshot::idle(FrameInstant::ZERO); idle.pointer = Some(PointerSample::new(center)); - let (frame, _) = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, idle); + let (frame, _) = drive_with_snap( + &mut shell, + &document, + &Mode::Idle, + &Selection::default(), + &mut focus, + &mut prev, + idle, + ); assert_eq!( frame.sketch_activated, Some(sketch_id), diff --git a/crates/bone-app/src/strings.rs b/crates/bone-app/src/strings.rs index 33e814e..8a77e8f 100644 --- a/crates/bone-app/src/strings.rs +++ b/crates/bone-app/src/strings.rs @@ -100,15 +100,38 @@ pub const MENU_HELP: StringKey = StringKey::new("menu.help"); pub const MENU_FILE_NEW: StringKey = StringKey::new("menu.file.new"); pub const MENU_FILE_OPEN: StringKey = StringKey::new("menu.file.open"); pub const MENU_FILE_SAVE: StringKey = StringKey::new("menu.file.save"); +pub const MENU_FILE_SAVE_AS: StringKey = StringKey::new("menu.file.save_as"); pub const MENU_FILE_QUIT: StringKey = StringKey::new("menu.file.quit"); +pub const FILE_PICKER_TITLE_OPEN: StringKey = StringKey::new("file_picker.title.open"); +pub const FILE_PICKER_TITLE_SAVE_AS: StringKey = StringKey::new("file_picker.title.save_as"); +pub const FILE_PICKER_OPEN: StringKey = StringKey::new("file_picker.open"); +pub const FILE_PICKER_SAVE: StringKey = StringKey::new("file_picker.save"); +pub const FILE_PICKER_CANCEL: StringKey = StringKey::new("file_picker.cancel"); +pub const FILE_PICKER_LIST: StringKey = StringKey::new("file_picker.list"); +pub const FILE_PICKER_FILENAME_PLACEHOLDER: StringKey = + StringKey::new("file_picker.filename_placeholder"); +pub const FILE_PICKER_DIR_EMPTY: StringKey = StringKey::new("file_picker.dir.empty"); +pub const FILE_OVERWRITE_TITLE: StringKey = StringKey::new("file.overwrite.title"); +pub const FILE_OVERWRITE_MESSAGE: StringKey = StringKey::new("file.overwrite.message"); +pub const FILE_OVERWRITE_REPLACE: StringKey = StringKey::new("file.overwrite.replace"); +pub const FILE_OVERWRITE_CANCEL: StringKey = StringKey::new("file.overwrite.cancel"); +pub const FILE_DISCARD_TITLE: StringKey = StringKey::new("file.discard.title"); +pub const FILE_DISCARD_MESSAGE: StringKey = StringKey::new("file.discard.message"); +pub const FILE_DISCARD_CONFIRM: StringKey = StringKey::new("file.discard.confirm"); +pub const FILE_DISCARD_CANCEL: StringKey = StringKey::new("file.discard.cancel"); +pub const DEFAULT_DOCUMENT_NAME: StringKey = StringKey::new("file.default_document_name"); +pub const NOTIFY_SAVE_FAILED: StringKey = StringKey::new("notify.save_failed"); +pub const NOTIFY_LOAD_FAILED: StringKey = StringKey::new("notify.load_failed"); +pub const NOTIFY_SCAN_FAILED: StringKey = StringKey::new("notify.scan_failed"); +pub const NOTIFY_SAVED: StringKey = StringKey::new("notify.saved"); +pub const NOTIFY_DISMISS: StringKey = StringKey::new("notify.dismiss"); pub const MENU_EDIT_UNDO: StringKey = StringKey::new("menu.edit.undo"); pub const MENU_EDIT_REDO: StringKey = StringKey::new("menu.edit.redo"); pub const MENU_VIEW_ZOOM_FIT: StringKey = StringKey::new("menu.view.zoom_fit"); pub const MENU_PLACEHOLDER_COMING_SOON: StringKey = StringKey::new("menu.placeholder.coming_soon"); pub const MENU_TOOLS_OPTIONS: StringKey = StringKey::new("menu.tools.options"); pub const SETTINGS_DIALOG_TITLE: StringKey = StringKey::new("settings.dialog.title"); -pub const SETTINGS_PICK_APERTURE_LABEL: StringKey = - StringKey::new("settings.pick_aperture.label"); +pub const SETTINGS_PICK_APERTURE_LABEL: StringKey = StringKey::new("settings.pick_aperture.label"); pub const SETTINGS_PICK_APERTURE_HINT: StringKey = StringKey::new("settings.pick_aperture.hint"); pub const SETTINGS_RESET: StringKey = StringKey::new("settings.reset"); pub const SETTINGS_CLOSE: StringKey = StringKey::new("settings.close"); @@ -260,9 +283,38 @@ const EN_US: &[(StringKey, &str)] = &[ (MENU_WINDOW, "Window"), (MENU_HELP, "Help"), (MENU_FILE_NEW, "New"), - (MENU_FILE_OPEN, "Open"), + (MENU_FILE_OPEN, "Open..."), (MENU_FILE_SAVE, "Save"), + (MENU_FILE_SAVE_AS, "Save As..."), (MENU_FILE_QUIT, "Quit"), + (FILE_PICKER_TITLE_OPEN, "Open Document"), + (FILE_PICKER_TITLE_SAVE_AS, "Save Document As"), + (FILE_PICKER_OPEN, "Open"), + (FILE_PICKER_SAVE, "Save"), + (FILE_PICKER_CANCEL, "Cancel"), + (FILE_PICKER_LIST, "Document folders"), + (FILE_PICKER_FILENAME_PLACEHOLDER, "Document name"), + (FILE_PICKER_DIR_EMPTY, "No documents in this folder"), + (FILE_OVERWRITE_TITLE, "Replace document?"), + ( + FILE_OVERWRITE_MESSAGE, + "A document already exists at this location.", + ), + (FILE_OVERWRITE_REPLACE, "Replace"), + (FILE_OVERWRITE_CANCEL, "Cancel"), + (FILE_DISCARD_TITLE, "Discard unsaved changes?"), + ( + FILE_DISCARD_MESSAGE, + "Continuing will permanently lose your unsaved edits.", + ), + (FILE_DISCARD_CONFIRM, "Discard"), + (FILE_DISCARD_CANCEL, "Keep editing"), + (DEFAULT_DOCUMENT_NAME, "Untitled"), + (NOTIFY_SAVE_FAILED, "Save failed"), + (NOTIFY_LOAD_FAILED, "Open failed"), + (NOTIFY_SCAN_FAILED, "Could not read documents folder"), + (NOTIFY_SAVED, "Saved"), + (NOTIFY_DISMISS, "Dismiss"), (MENU_EDIT_UNDO, "Undo"), (MENU_EDIT_REDO, "Redo"), (MENU_VIEW_ZOOM_FIT, "Zoom to Fit"), @@ -417,9 +469,41 @@ const AR_XB: &[(StringKey, &str)] = &[ (MENU_WINDOW, "[!! Wîndow !!]"), (MENU_HELP, "[!! Hêlp !!]"), (MENU_FILE_NEW, "[!! Néw !!]"), - (MENU_FILE_OPEN, "[!! Ôpen !!]"), + (MENU_FILE_OPEN, "[!! Ôpen... !!]"), (MENU_FILE_SAVE, "[!! Sâve !!]"), + (MENU_FILE_SAVE_AS, "[!! Sâve Âs... !!]"), (MENU_FILE_QUIT, "[!! Quît !!]"), + (FILE_PICKER_TITLE_OPEN, "[!! Ôpen Dôcument !!]"), + (FILE_PICKER_TITLE_SAVE_AS, "[!! Sâve Dôcument Âs !!]"), + (FILE_PICKER_OPEN, "[!! Ôpen !!]"), + (FILE_PICKER_SAVE, "[!! Sâve !!]"), + (FILE_PICKER_CANCEL, "[!! Cancêl !!]"), + (FILE_PICKER_LIST, "[!! Dôcument fôlders !!]"), + (FILE_PICKER_FILENAME_PLACEHOLDER, "[!! Dôcument nâme !!]"), + (FILE_PICKER_DIR_EMPTY, "[!! Nô dôcuments în thîs fôlder !!]"), + (FILE_OVERWRITE_TITLE, "[!! Replâce dôcument? !!]"), + ( + FILE_OVERWRITE_MESSAGE, + "[!! Â dôcument alrêady êxists ât thîs locâtion. !!]", + ), + (FILE_OVERWRITE_REPLACE, "[!! Replâce !!]"), + (FILE_OVERWRITE_CANCEL, "[!! Cancêl !!]"), + (FILE_DISCARD_TITLE, "[!! Discârd unsâved chânges? !!]"), + ( + FILE_DISCARD_MESSAGE, + "[!! Continûing will pêrmanently lôse yôur unsâved edîts. !!]", + ), + (FILE_DISCARD_CONFIRM, "[!! Discârd !!]"), + (FILE_DISCARD_CANCEL, "[!! Kêep edîting !!]"), + (DEFAULT_DOCUMENT_NAME, "[!! Untîtled !!]"), + (NOTIFY_SAVE_FAILED, "[!! Sâve fâiled !!]"), + (NOTIFY_LOAD_FAILED, "[!! Ôpen fâiled !!]"), + ( + NOTIFY_SCAN_FAILED, + "[!! Côuld not rêad dôcuments fôlder !!]", + ), + (NOTIFY_SAVED, "[!! Sâved !!]"), + (NOTIFY_DISMISS, "[!! Dîsmiss !!]"), (MENU_EDIT_UNDO, "[!! Undô !!]"), (MENU_EDIT_REDO, "[!! Redô !!]"), (MENU_VIEW_ZOOM_FIT, "[!! Zôom to Fît !!]"), diff --git a/crates/bone-document/src/io/folder.rs b/crates/bone-document/src/io/folder.rs index b6e7713..4bde94a 100644 --- a/crates/bone-document/src/io/folder.rs +++ b/crates/bone-document/src/io/folder.rs @@ -154,11 +154,11 @@ pub fn save(document: &Document, folder: &DocumentFolder) -> Result<(), FolderEr .try_for_each(|(id, sketch)| -> Result<(), FolderError> { let file = SketchFile::new(sketch.clone()); let ron = to_ron(&folder.sketch_path(id), &file)?; - atomic_write(&folder.sketch_path(id), &ron) + write_if_different(&folder.sketch_path(id), &ron) })?; let document_ron = to_ron(&folder.document_file(), document.header())?; - atomic_write(&folder.document_file(), &document_ron)?; + write_if_different(&folder.document_file(), &document_ron)?; let registry_ids: BTreeSet = document.registry().order().iter().copied().collect(); remove_stale_sketches(&folder.sketches_dir(), ®istry_ids)?;