Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(app): file new/open/save

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (May 17, 2026, 10:10 PM +0300) commit 3f6e4e52 parent 8d3c4bd9 change-id pnnlxoll
+2452 -132
+1
.gitignore
··· 2 2 /result 3 3 *.swp 4 4 .direnv/ 5 + /bone-documents/
+638 -6
Cargo.lock
··· 40 40 checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" 41 41 dependencies = [ 42 42 "cfg-if", 43 - "getrandom", 43 + "getrandom 0.3.4", 44 44 "once_cell", 45 45 "version_check", 46 46 "zerocopy", ··· 96 96 ] 97 97 98 98 [[package]] 99 + name = "anyhow" 100 + version = "1.0.102" 101 + source = "registry+https://github.com/rust-lang/crates.io-index" 102 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 103 + 104 + [[package]] 99 105 name = "approx" 100 106 version = "0.5.1" 101 107 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 132 138 ] 133 139 134 140 [[package]] 141 + name = "ashpd" 142 + version = "0.13.11" 143 + source = "registry+https://github.com/rust-lang/crates.io-index" 144 + checksum = "340e0f6bf7f9ee78549c61454f1460a3ed97c011902ee76b58301bbc6d502a32" 145 + dependencies = [ 146 + "enumflags2", 147 + "futures-util", 148 + "getrandom 0.4.2", 149 + "serde", 150 + "serde_repr", 151 + "zbus", 152 + ] 153 + 154 + [[package]] 155 + name = "async-broadcast" 156 + version = "0.7.2" 157 + source = "registry+https://github.com/rust-lang/crates.io-index" 158 + checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" 159 + dependencies = [ 160 + "event-listener", 161 + "event-listener-strategy", 162 + "futures-core", 163 + "pin-project-lite", 164 + ] 165 + 166 + [[package]] 167 + name = "async-channel" 168 + version = "2.5.0" 169 + source = "registry+https://github.com/rust-lang/crates.io-index" 170 + checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" 171 + dependencies = [ 172 + "concurrent-queue", 173 + "event-listener-strategy", 174 + "futures-core", 175 + "pin-project-lite", 176 + ] 177 + 178 + [[package]] 179 + name = "async-executor" 180 + version = "1.14.0" 181 + source = "registry+https://github.com/rust-lang/crates.io-index" 182 + checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" 183 + dependencies = [ 184 + "async-task", 185 + "concurrent-queue", 186 + "fastrand", 187 + "futures-lite", 188 + "pin-project-lite", 189 + "slab", 190 + ] 191 + 192 + [[package]] 193 + name = "async-io" 194 + version = "2.6.0" 195 + source = "registry+https://github.com/rust-lang/crates.io-index" 196 + checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" 197 + dependencies = [ 198 + "autocfg", 199 + "cfg-if", 200 + "concurrent-queue", 201 + "futures-io", 202 + "futures-lite", 203 + "parking", 204 + "polling", 205 + "rustix 1.1.4", 206 + "slab", 207 + "windows-sys 0.61.2", 208 + ] 209 + 210 + [[package]] 211 + name = "async-lock" 212 + version = "3.4.2" 213 + source = "registry+https://github.com/rust-lang/crates.io-index" 214 + checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" 215 + dependencies = [ 216 + "event-listener", 217 + "event-listener-strategy", 218 + "pin-project-lite", 219 + ] 220 + 221 + [[package]] 222 + name = "async-process" 223 + version = "2.5.0" 224 + source = "registry+https://github.com/rust-lang/crates.io-index" 225 + checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" 226 + dependencies = [ 227 + "async-channel", 228 + "async-io", 229 + "async-lock", 230 + "async-signal", 231 + "async-task", 232 + "blocking", 233 + "cfg-if", 234 + "event-listener", 235 + "futures-lite", 236 + "rustix 1.1.4", 237 + ] 238 + 239 + [[package]] 240 + name = "async-recursion" 241 + version = "1.1.1" 242 + source = "registry+https://github.com/rust-lang/crates.io-index" 243 + checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" 244 + dependencies = [ 245 + "proc-macro2", 246 + "quote", 247 + "syn 2.0.117", 248 + ] 249 + 250 + [[package]] 251 + name = "async-signal" 252 + version = "0.2.14" 253 + source = "registry+https://github.com/rust-lang/crates.io-index" 254 + checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" 255 + dependencies = [ 256 + "async-io", 257 + "async-lock", 258 + "atomic-waker", 259 + "cfg-if", 260 + "futures-core", 261 + "futures-io", 262 + "rustix 1.1.4", 263 + "signal-hook-registry", 264 + "slab", 265 + "windows-sys 0.61.2", 266 + ] 267 + 268 + [[package]] 269 + name = "async-task" 270 + version = "4.7.1" 271 + source = "registry+https://github.com/rust-lang/crates.io-index" 272 + checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" 273 + 274 + [[package]] 275 + name = "async-trait" 276 + version = "0.1.89" 277 + source = "registry+https://github.com/rust-lang/crates.io-index" 278 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 279 + dependencies = [ 280 + "proc-macro2", 281 + "quote", 282 + "syn 2.0.117", 283 + ] 284 + 285 + [[package]] 135 286 name = "atomic-waker" 136 287 version = "1.1.2" 137 288 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 206 357 ] 207 358 208 359 [[package]] 360 + name = "blocking" 361 + version = "1.6.2" 362 + source = "registry+https://github.com/rust-lang/crates.io-index" 363 + checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" 364 + dependencies = [ 365 + "async-channel", 366 + "async-task", 367 + "futures-io", 368 + "futures-lite", 369 + "piper", 370 + ] 371 + 372 + [[package]] 209 373 name = "bone-app" 210 374 version = "0.0.0" 211 375 dependencies = [ 376 + "ashpd", 212 377 "bone-document", 213 378 "bone-render", 214 379 "bone-text", 215 380 "bone-types", 216 381 "bone-ui", 382 + "percent-encoding", 217 383 "pollster", 218 384 "swash", 219 385 "thiserror 2.0.18", ··· 640 806 checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" 641 807 642 808 [[package]] 809 + name = "endi" 810 + version = "1.1.1" 811 + source = "registry+https://github.com/rust-lang/crates.io-index" 812 + checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" 813 + 814 + [[package]] 643 815 name = "enum-as-inner" 644 816 version = "0.6.1" 645 817 source = "registry+https://github.com/rust-lang/crates.io-index" 646 818 checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" 647 819 dependencies = [ 648 820 "heck", 821 + "proc-macro2", 822 + "quote", 823 + "syn 2.0.117", 824 + ] 825 + 826 + [[package]] 827 + name = "enumflags2" 828 + version = "0.7.12" 829 + source = "registry+https://github.com/rust-lang/crates.io-index" 830 + checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" 831 + dependencies = [ 832 + "enumflags2_derive", 833 + "serde", 834 + ] 835 + 836 + [[package]] 837 + name = "enumflags2_derive" 838 + version = "0.7.12" 839 + source = "registry+https://github.com/rust-lang/crates.io-index" 840 + checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" 841 + dependencies = [ 649 842 "proc-macro2", 650 843 "quote", 651 844 "syn 2.0.117", ··· 709 902 checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" 710 903 dependencies = [ 711 904 "num-traits", 905 + ] 906 + 907 + [[package]] 908 + name = "event-listener" 909 + version = "5.4.1" 910 + source = "registry+https://github.com/rust-lang/crates.io-index" 911 + checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" 912 + dependencies = [ 913 + "concurrent-queue", 914 + "parking", 915 + "pin-project-lite", 916 + ] 917 + 918 + [[package]] 919 + name = "event-listener-strategy" 920 + version = "0.5.4" 921 + source = "registry+https://github.com/rust-lang/crates.io-index" 922 + checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" 923 + dependencies = [ 924 + "event-listener", 925 + "pin-project-lite", 712 926 ] 713 927 714 928 [[package]] ··· 861 1075 checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 862 1076 863 1077 [[package]] 1078 + name = "futures-io" 1079 + version = "0.3.32" 1080 + source = "registry+https://github.com/rust-lang/crates.io-index" 1081 + checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" 1082 + 1083 + [[package]] 1084 + name = "futures-lite" 1085 + version = "2.6.1" 1086 + source = "registry+https://github.com/rust-lang/crates.io-index" 1087 + checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" 1088 + dependencies = [ 1089 + "fastrand", 1090 + "futures-core", 1091 + "futures-io", 1092 + "parking", 1093 + "pin-project-lite", 1094 + ] 1095 + 1096 + [[package]] 1097 + name = "futures-macro" 1098 + version = "0.3.32" 1099 + source = "registry+https://github.com/rust-lang/crates.io-index" 1100 + checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" 1101 + dependencies = [ 1102 + "proc-macro2", 1103 + "quote", 1104 + "syn 2.0.117", 1105 + ] 1106 + 1107 + [[package]] 864 1108 name = "futures-task" 865 1109 version = "0.3.32" 866 1110 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 873 1117 checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 874 1118 dependencies = [ 875 1119 "futures-core", 1120 + "futures-io", 1121 + "futures-macro", 876 1122 "futures-task", 1123 + "memchr", 877 1124 "pin-project-lite", 878 1125 "slab", 879 1126 ] ··· 1019 1266 dependencies = [ 1020 1267 "cfg-if", 1021 1268 "libc", 1022 - "r-efi", 1269 + "r-efi 5.3.0", 1023 1270 "wasip2", 1024 1271 ] 1025 1272 1026 1273 [[package]] 1274 + name = "getrandom" 1275 + version = "0.4.2" 1276 + source = "registry+https://github.com/rust-lang/crates.io-index" 1277 + checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" 1278 + dependencies = [ 1279 + "cfg-if", 1280 + "libc", 1281 + "r-efi 6.0.0", 1282 + "wasip2", 1283 + "wasip3", 1284 + ] 1285 + 1286 + [[package]] 1027 1287 name = "gl_generator" 1028 1288 version = "0.14.0" 1029 1289 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1163 1423 checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 1164 1424 1165 1425 [[package]] 1426 + name = "hex" 1427 + version = "0.4.3" 1428 + source = "registry+https://github.com/rust-lang/crates.io-index" 1429 + checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 1430 + 1431 + [[package]] 1166 1432 name = "hexf-parse" 1167 1433 version = "0.2.1" 1168 1434 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1296 1562 checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" 1297 1563 1298 1564 [[package]] 1565 + name = "id-arena" 1566 + version = "2.3.0" 1567 + source = "registry+https://github.com/rust-lang/crates.io-index" 1568 + checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" 1569 + 1570 + [[package]] 1299 1571 name = "indexmap" 1300 1572 version = "2.14.0" 1301 1573 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1303 1575 dependencies = [ 1304 1576 "equivalent", 1305 1577 "hashbrown 0.17.0", 1578 + "serde", 1579 + "serde_core", 1306 1580 ] 1307 1581 1308 1582 [[package]] ··· 1331 1605 ] 1332 1606 1333 1607 [[package]] 1608 + name = "itoa" 1609 + version = "1.0.18" 1610 + source = "registry+https://github.com/rust-lang/crates.io-index" 1611 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 1612 + 1613 + [[package]] 1334 1614 name = "jni" 1335 1615 version = "0.22.4" 1336 1616 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1394 1674 source = "registry+https://github.com/rust-lang/crates.io-index" 1395 1675 checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" 1396 1676 dependencies = [ 1397 - "getrandom", 1677 + "getrandom 0.3.4", 1398 1678 "libc", 1399 1679 ] 1400 1680 ··· 1432 1712 version = "1.5.0" 1433 1713 source = "registry+https://github.com/rust-lang/crates.io-index" 1434 1714 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1715 + 1716 + [[package]] 1717 + name = "leb128fmt" 1718 + version = "0.1.0" 1719 + source = "registry+https://github.com/rust-lang/crates.io-index" 1720 + checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 1435 1721 1436 1722 [[package]] 1437 1723 name = "libc" ··· 1576 1862 checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" 1577 1863 dependencies = [ 1578 1864 "libc", 1865 + ] 1866 + 1867 + [[package]] 1868 + name = "memoffset" 1869 + version = "0.9.1" 1870 + source = "registry+https://github.com/rust-lang/crates.io-index" 1871 + checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" 1872 + dependencies = [ 1873 + "autocfg", 1579 1874 ] 1580 1875 1581 1876 [[package]] ··· 2105 2400 ] 2106 2401 2107 2402 [[package]] 2403 + name = "ordered-stream" 2404 + version = "0.2.0" 2405 + source = "registry+https://github.com/rust-lang/crates.io-index" 2406 + checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" 2407 + dependencies = [ 2408 + "futures-core", 2409 + "pin-project-lite", 2410 + ] 2411 + 2412 + [[package]] 2108 2413 name = "owned_ttf_parser" 2109 2414 version = "0.25.1" 2110 2415 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2135 2440 "quote", 2136 2441 "syn 2.0.117", 2137 2442 ] 2443 + 2444 + [[package]] 2445 + name = "parking" 2446 + version = "2.2.1" 2447 + source = "registry+https://github.com/rust-lang/crates.io-index" 2448 + checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" 2138 2449 2139 2450 [[package]] 2140 2451 name = "parking_lot" ··· 2231 2542 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" 2232 2543 2233 2544 [[package]] 2545 + name = "piper" 2546 + version = "0.2.5" 2547 + source = "registry+https://github.com/rust-lang/crates.io-index" 2548 + checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" 2549 + dependencies = [ 2550 + "atomic-waker", 2551 + "fastrand", 2552 + "futures-io", 2553 + ] 2554 + 2555 + [[package]] 2234 2556 name = "pkg-config" 2235 2557 version = "0.3.33" 2236 2558 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2317 2639 checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" 2318 2640 2319 2641 [[package]] 2642 + name = "prettyplease" 2643 + version = "0.2.37" 2644 + source = "registry+https://github.com/rust-lang/crates.io-index" 2645 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 2646 + dependencies = [ 2647 + "proc-macro2", 2648 + "syn 2.0.117", 2649 + ] 2650 + 2651 + [[package]] 2320 2652 name = "private-gemm-x86" 2321 2653 version = "0.1.20" 2322 2654 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2427 2759 checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 2428 2760 2429 2761 [[package]] 2762 + name = "r-efi" 2763 + version = "6.0.0" 2764 + source = "registry+https://github.com/rust-lang/crates.io-index" 2765 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 2766 + 2767 + [[package]] 2430 2768 name = "rand" 2431 2769 version = "0.9.4" 2432 2770 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2452 2790 source = "registry+https://github.com/rust-lang/crates.io-index" 2453 2791 checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" 2454 2792 dependencies = [ 2455 - "getrandom", 2793 + "getrandom 0.3.4", 2456 2794 ] 2457 2795 2458 2796 [[package]] ··· 2727 3065 ] 2728 3066 2729 3067 [[package]] 3068 + name = "serde_json" 3069 + version = "1.0.149" 3070 + source = "registry+https://github.com/rust-lang/crates.io-index" 3071 + checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" 3072 + dependencies = [ 3073 + "itoa", 3074 + "memchr", 3075 + "serde", 3076 + "serde_core", 3077 + "zmij", 3078 + ] 3079 + 3080 + [[package]] 3081 + name = "serde_repr" 3082 + version = "0.1.20" 3083 + source = "registry+https://github.com/rust-lang/crates.io-index" 3084 + checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" 3085 + dependencies = [ 3086 + "proc-macro2", 3087 + "quote", 3088 + "syn 2.0.117", 3089 + ] 3090 + 3091 + [[package]] 2730 3092 name = "sharded-slab" 2731 3093 version = "0.1.7" 2732 3094 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2742 3104 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 2743 3105 2744 3106 [[package]] 3107 + name = "signal-hook-registry" 3108 + version = "1.4.8" 3109 + source = "registry+https://github.com/rust-lang/crates.io-index" 3110 + checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" 3111 + dependencies = [ 3112 + "errno", 3113 + "libc", 3114 + ] 3115 + 3116 + [[package]] 2745 3117 name = "simba" 2746 3118 version = "0.9.1" 2747 3119 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2962 3334 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" 2963 3335 dependencies = [ 2964 3336 "fastrand", 2965 - "getrandom", 3337 + "getrandom 0.3.4", 2966 3338 "once_cell", 2967 3339 "rustix 1.1.4", 2968 3340 "windows-sys 0.61.2", ··· 3172 3544 checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" 3173 3545 3174 3546 [[package]] 3547 + name = "uds_windows" 3548 + version = "1.2.1" 3549 + source = "registry+https://github.com/rust-lang/crates.io-index" 3550 + checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" 3551 + dependencies = [ 3552 + "memoffset", 3553 + "tempfile", 3554 + "windows-sys 0.61.2", 3555 + ] 3556 + 3557 + [[package]] 3175 3558 name = "unarray" 3176 3559 version = "0.1.4" 3177 3560 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3196 3579 checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 3197 3580 3198 3581 [[package]] 3582 + name = "unicode-xid" 3583 + version = "0.2.6" 3584 + source = "registry+https://github.com/rust-lang/crates.io-index" 3585 + checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 3586 + 3587 + [[package]] 3199 3588 name = "uom" 3200 3589 version = "0.38.0" 3201 3590 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3216 3605 version = "1.23.1" 3217 3606 source = "registry+https://github.com/rust-lang/crates.io-index" 3218 3607 checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" 3608 + dependencies = [ 3609 + "js-sys", 3610 + "serde_core", 3611 + "wasm-bindgen", 3612 + ] 3219 3613 3220 3614 [[package]] 3221 3615 name = "valuable" ··· 3245 3639 source = "registry+https://github.com/rust-lang/crates.io-index" 3246 3640 checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" 3247 3641 dependencies = [ 3248 - "wit-bindgen", 3642 + "wit-bindgen 0.57.1", 3643 + ] 3644 + 3645 + [[package]] 3646 + name = "wasip3" 3647 + version = "0.4.0+wasi-0.3.0-rc-2026-01-06" 3648 + source = "registry+https://github.com/rust-lang/crates.io-index" 3649 + checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" 3650 + dependencies = [ 3651 + "wit-bindgen 0.51.0", 3249 3652 ] 3250 3653 3251 3654 [[package]] ··· 3301 3704 checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" 3302 3705 dependencies = [ 3303 3706 "unicode-ident", 3707 + ] 3708 + 3709 + [[package]] 3710 + name = "wasm-encoder" 3711 + version = "0.244.0" 3712 + source = "registry+https://github.com/rust-lang/crates.io-index" 3713 + checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" 3714 + dependencies = [ 3715 + "leb128fmt", 3716 + "wasmparser", 3717 + ] 3718 + 3719 + [[package]] 3720 + name = "wasm-metadata" 3721 + version = "0.244.0" 3722 + source = "registry+https://github.com/rust-lang/crates.io-index" 3723 + checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" 3724 + dependencies = [ 3725 + "anyhow", 3726 + "indexmap", 3727 + "wasm-encoder", 3728 + "wasmparser", 3729 + ] 3730 + 3731 + [[package]] 3732 + name = "wasmparser" 3733 + version = "0.244.0" 3734 + source = "registry+https://github.com/rust-lang/crates.io-index" 3735 + checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" 3736 + dependencies = [ 3737 + "bitflags 2.11.1", 3738 + "hashbrown 0.15.5", 3739 + "indexmap", 3740 + "semver", 3304 3741 ] 3305 3742 3306 3743 [[package]] ··· 3882 4319 3883 4320 [[package]] 3884 4321 name = "wit-bindgen" 4322 + version = "0.51.0" 4323 + source = "registry+https://github.com/rust-lang/crates.io-index" 4324 + checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 4325 + dependencies = [ 4326 + "wit-bindgen-rust-macro", 4327 + ] 4328 + 4329 + [[package]] 4330 + name = "wit-bindgen" 3885 4331 version = "0.57.1" 3886 4332 source = "registry+https://github.com/rust-lang/crates.io-index" 3887 4333 checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" 3888 4334 3889 4335 [[package]] 4336 + name = "wit-bindgen-core" 4337 + version = "0.51.0" 4338 + source = "registry+https://github.com/rust-lang/crates.io-index" 4339 + checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" 4340 + dependencies = [ 4341 + "anyhow", 4342 + "heck", 4343 + "wit-parser", 4344 + ] 4345 + 4346 + [[package]] 4347 + name = "wit-bindgen-rust" 4348 + version = "0.51.0" 4349 + source = "registry+https://github.com/rust-lang/crates.io-index" 4350 + checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" 4351 + dependencies = [ 4352 + "anyhow", 4353 + "heck", 4354 + "indexmap", 4355 + "prettyplease", 4356 + "syn 2.0.117", 4357 + "wasm-metadata", 4358 + "wit-bindgen-core", 4359 + "wit-component", 4360 + ] 4361 + 4362 + [[package]] 4363 + name = "wit-bindgen-rust-macro" 4364 + version = "0.51.0" 4365 + source = "registry+https://github.com/rust-lang/crates.io-index" 4366 + checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" 4367 + dependencies = [ 4368 + "anyhow", 4369 + "prettyplease", 4370 + "proc-macro2", 4371 + "quote", 4372 + "syn 2.0.117", 4373 + "wit-bindgen-core", 4374 + "wit-bindgen-rust", 4375 + ] 4376 + 4377 + [[package]] 4378 + name = "wit-component" 4379 + version = "0.244.0" 4380 + source = "registry+https://github.com/rust-lang/crates.io-index" 4381 + checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" 4382 + dependencies = [ 4383 + "anyhow", 4384 + "bitflags 2.11.1", 4385 + "indexmap", 4386 + "log", 4387 + "serde", 4388 + "serde_derive", 4389 + "serde_json", 4390 + "wasm-encoder", 4391 + "wasm-metadata", 4392 + "wasmparser", 4393 + "wit-parser", 4394 + ] 4395 + 4396 + [[package]] 4397 + name = "wit-parser" 4398 + version = "0.244.0" 4399 + source = "registry+https://github.com/rust-lang/crates.io-index" 4400 + checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" 4401 + dependencies = [ 4402 + "anyhow", 4403 + "id-arena", 4404 + "indexmap", 4405 + "log", 4406 + "semver", 4407 + "serde", 4408 + "serde_derive", 4409 + "serde_json", 4410 + "unicode-xid", 4411 + "wasmparser", 4412 + ] 4413 + 4414 + [[package]] 3890 4415 name = "writeable" 3891 4416 version = "0.6.3" 3892 4417 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3985 4510 ] 3986 4511 3987 4512 [[package]] 4513 + name = "zbus" 4514 + version = "5.15.0" 4515 + source = "registry+https://github.com/rust-lang/crates.io-index" 4516 + checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" 4517 + dependencies = [ 4518 + "async-broadcast", 4519 + "async-executor", 4520 + "async-io", 4521 + "async-lock", 4522 + "async-process", 4523 + "async-recursion", 4524 + "async-task", 4525 + "async-trait", 4526 + "blocking", 4527 + "enumflags2", 4528 + "event-listener", 4529 + "futures-core", 4530 + "futures-lite", 4531 + "hex", 4532 + "libc", 4533 + "ordered-stream", 4534 + "rustix 1.1.4", 4535 + "serde", 4536 + "serde_repr", 4537 + "tracing", 4538 + "uds_windows", 4539 + "uuid", 4540 + "windows-sys 0.61.2", 4541 + "winnow", 4542 + "zbus_macros", 4543 + "zbus_names", 4544 + "zvariant", 4545 + ] 4546 + 4547 + [[package]] 4548 + name = "zbus_macros" 4549 + version = "5.15.0" 4550 + source = "registry+https://github.com/rust-lang/crates.io-index" 4551 + checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" 4552 + dependencies = [ 4553 + "proc-macro-crate", 4554 + "proc-macro2", 4555 + "quote", 4556 + "syn 2.0.117", 4557 + "zbus_names", 4558 + "zvariant", 4559 + "zvariant_utils", 4560 + ] 4561 + 4562 + [[package]] 4563 + name = "zbus_names" 4564 + version = "4.3.2" 4565 + source = "registry+https://github.com/rust-lang/crates.io-index" 4566 + checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" 4567 + dependencies = [ 4568 + "serde", 4569 + "winnow", 4570 + "zvariant", 4571 + ] 4572 + 4573 + [[package]] 3988 4574 name = "zeno" 3989 4575 version = "0.3.3" 3990 4576 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4065 4651 "quote", 4066 4652 "syn 2.0.117", 4067 4653 ] 4654 + 4655 + [[package]] 4656 + name = "zmij" 4657 + version = "1.0.21" 4658 + source = "registry+https://github.com/rust-lang/crates.io-index" 4659 + checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" 4660 + 4661 + [[package]] 4662 + name = "zvariant" 4663 + version = "5.11.0" 4664 + source = "registry+https://github.com/rust-lang/crates.io-index" 4665 + checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" 4666 + dependencies = [ 4667 + "endi", 4668 + "enumflags2", 4669 + "serde", 4670 + "winnow", 4671 + "zvariant_derive", 4672 + "zvariant_utils", 4673 + ] 4674 + 4675 + [[package]] 4676 + name = "zvariant_derive" 4677 + version = "5.11.0" 4678 + source = "registry+https://github.com/rust-lang/crates.io-index" 4679 + checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" 4680 + dependencies = [ 4681 + "proc-macro-crate", 4682 + "proc-macro2", 4683 + "quote", 4684 + "syn 2.0.117", 4685 + "zvariant_utils", 4686 + ] 4687 + 4688 + [[package]] 4689 + name = "zvariant_utils" 4690 + version = "3.3.1" 4691 + source = "registry+https://github.com/rust-lang/crates.io-index" 4692 + checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" 4693 + dependencies = [ 4694 + "proc-macro2", 4695 + "quote", 4696 + "serde", 4697 + "syn 2.0.117", 4698 + "winnow", 4699 + ]
+2
Cargo.toml
··· 40 40 bone-ui = { path = "crates/bone-ui" } 41 41 42 42 accesskit = "0.24" 43 + ashpd = { version = "0.13", default-features = false, features = ["async-io", "file_chooser"] } 43 44 blake3 = { version = "1", default-features = false, features = ["std"] } 44 45 bytemuck = { version = "1", default-features = false, features = ["derive"] } 45 46 faer = { version = "0.24", default-features = false, features = ["std"] } ··· 48 49 nalgebra = { version = "0.33", default-features = false, features = ["std"] } 49 50 palette = { version = "0.7", default-features = false, features = ["std"] } 50 51 parley = { version = "0.9", default-features = false, features = ["std"] } 52 + percent-encoding = { version = "2", default-features = false, features = ["std"] } 51 53 png = { version = "0.17", default-features = false } 52 54 pollster = "0.4" 53 55 proptest = { version = "1", default-features = false, features = ["std"] }
+4
crates/bone-app/Cargo.toml
··· 21 21 uom = { workspace = true } 22 22 winit = { workspace = true } 23 23 24 + [target.'cfg(target_os = "linux")'.dependencies] 25 + ashpd = { workspace = true } 26 + percent-encoding = { workspace = true } 27 + 24 28 [lints] 25 29 workspace = true
+498
crates/bone-app/src/file_menu.rs
··· 1 + use std::path::{Component, Path, PathBuf}; 2 + 3 + use bone_ui::frame::FrameCtx; 4 + use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 5 + use bone_ui::widgets::{ 6 + FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerOutcome, FilePickerState, 7 + LabelText, WidgetPaint, show_file_picker, 8 + }; 9 + use bone_ui::{WidgetId, WidgetKey}; 10 + 11 + use crate::strings; 12 + 13 + pub const DEFAULT_DOCUMENTS_SUBDIR: &str = "bone-documents"; 14 + 15 + #[derive(Clone, Debug, PartialEq, Eq)] 16 + pub struct PickerEntry { 17 + pub id: WidgetId, 18 + pub label: String, 19 + pub path: PathBuf, 20 + } 21 + 22 + pub struct FilePickerSession { 23 + pub mode: FilePickerMode, 24 + pub root: PathBuf, 25 + pub entries: Vec<PickerEntry>, 26 + pub state: FilePickerState, 27 + } 28 + 29 + impl FilePickerSession { 30 + #[must_use] 31 + pub fn open( 32 + root: PathBuf, 33 + mode: FilePickerMode, 34 + seed_filename: Option<String>, 35 + entries: Vec<PickerEntry>, 36 + ) -> Self { 37 + let mut state = FilePickerState::default(); 38 + if let Some(name) = seed_filename { 39 + state.filename.text = name; 40 + } 41 + Self { 42 + mode, 43 + root, 44 + entries, 45 + state, 46 + } 47 + } 48 + } 49 + 50 + #[derive(Clone, Debug, PartialEq, Eq)] 51 + pub enum PickerCommand { 52 + Cancel, 53 + OpenFolder(PathBuf), 54 + SaveAs(PathBuf), 55 + } 56 + 57 + pub struct PickerModalOutcome { 58 + pub paints: Vec<WidgetPaint>, 59 + pub command: Option<PickerCommand>, 60 + } 61 + 62 + pub fn render( 63 + ctx: &mut FrameCtx<'_>, 64 + session: &mut FilePickerSession, 65 + viewport: LayoutSize, 66 + ) -> PickerModalOutcome { 67 + let viewport_rect = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), viewport); 68 + let entries: Vec<FilePickerEntry> = session 69 + .entries 70 + .iter() 71 + .map(|e| FilePickerEntry { 72 + id: e.id, 73 + label: LabelText::Owned(e.label.clone()), 74 + }) 75 + .collect(); 76 + let title = match session.mode { 77 + FilePickerMode::Open => strings::FILE_PICKER_TITLE_OPEN, 78 + FilePickerMode::Save => strings::FILE_PICKER_TITLE_SAVE_AS, 79 + }; 80 + let current_path = session.root.display().to_string(); 81 + let entries_empty = entries.is_empty(); 82 + let response = show_file_picker( 83 + ctx, 84 + FilePickerDialog::new( 85 + picker_id(), 86 + viewport_rect, 87 + session.mode, 88 + current_path, 89 + &entries, 90 + title, 91 + &mut session.state, 92 + ), 93 + ); 94 + let mut paints = response.paint; 95 + if entries_empty { 96 + paints.push(empty_state_label(ctx, viewport_rect)); 97 + } 98 + let command = response 99 + .outcome 100 + .and_then(|o| translate(o, &session.entries, &session.root)); 101 + PickerModalOutcome { paints, command } 102 + } 103 + 104 + fn empty_state_label(ctx: &FrameCtx<'_>, viewport: LayoutRect) -> WidgetPaint { 105 + let rect = LayoutRect::new( 106 + LayoutPos::new( 107 + LayoutPx::new(viewport.origin.x.value() + viewport.size.width.value() * 0.5 - 160.0), 108 + LayoutPx::new(viewport.origin.y.value() + viewport.size.height.value() * 0.5 - 12.0), 109 + ), 110 + LayoutSize::new(LayoutPx::new(320.0), LayoutPx::new(24.0)), 111 + ); 112 + WidgetPaint::Label { 113 + rect, 114 + text: LabelText::Key(strings::FILE_PICKER_DIR_EMPTY), 115 + color: ctx.theme().colors.text_secondary(), 116 + role: ctx.theme().typography.body, 117 + } 118 + } 119 + 120 + fn translate( 121 + outcome: FilePickerOutcome, 122 + entries: &[PickerEntry], 123 + root: &Path, 124 + ) -> Option<PickerCommand> { 125 + match outcome { 126 + FilePickerOutcome::Cancelled => Some(PickerCommand::Cancel), 127 + FilePickerOutcome::Open { folder } => entries 128 + .iter() 129 + .find(|e| e.id == folder) 130 + .map(|e| PickerCommand::OpenFolder(e.path.clone())), 131 + FilePickerOutcome::Save { folder, filename } => { 132 + if filename.is_empty() { 133 + return folder 134 + .and_then(|f| entries.iter().find(|e| e.id == f)) 135 + .map(|e| PickerCommand::SaveAs(e.path.clone())); 136 + } 137 + let Some(name) = validate_save_name(filename.as_str()) else { 138 + tracing::warn!(input = filename.as_str(), "rejected save filename"); 139 + return None; 140 + }; 141 + Some(PickerCommand::SaveAs(root.join(name))) 142 + } 143 + } 144 + } 145 + 146 + const WINDOWS_RESERVED: &[&str] = &[ 147 + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", 148 + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", 149 + ]; 150 + const WINDOWS_RESERVED_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*']; 151 + pub const MAX_SAVE_NAME_BYTES: usize = 200; 152 + 153 + #[must_use] 154 + pub fn validate_save_name(name: &str) -> Option<&str> { 155 + if name.is_empty() || name.trim().is_empty() { 156 + return None; 157 + } 158 + if name.len() > MAX_SAVE_NAME_BYTES { 159 + return None; 160 + } 161 + if name != name.trim() { 162 + return None; 163 + } 164 + if name.ends_with('.') { 165 + return None; 166 + } 167 + if name 168 + .chars() 169 + .any(|c| c.is_control() || WINDOWS_RESERVED_CHARS.contains(&c)) 170 + { 171 + return None; 172 + } 173 + let stem = name.split('.').next().unwrap_or(""); 174 + if WINDOWS_RESERVED 175 + .iter() 176 + .any(|r| stem.eq_ignore_ascii_case(r)) 177 + { 178 + return None; 179 + } 180 + let path = Path::new(name); 181 + let mut components = path.components(); 182 + let first = components.next()?; 183 + if components.next().is_some() { 184 + return None; 185 + } 186 + match first { 187 + Component::Normal(os) => os.to_str().filter(|s| !s.is_empty()), 188 + Component::CurDir | Component::ParentDir | Component::Prefix(_) | Component::RootDir => { 189 + None 190 + } 191 + } 192 + } 193 + 194 + pub fn picker_id() -> WidgetId { 195 + WidgetId::ROOT.child(WidgetKey::new("app.file_picker")) 196 + } 197 + 198 + pub fn scan_document_folders(root: &Path) -> Result<Vec<PickerEntry>, std::io::Error> { 199 + let read = match std::fs::read_dir(root) { 200 + Ok(read) => read, 201 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), 202 + Err(e) => return Err(e), 203 + }; 204 + let mut entries: Vec<PickerEntry> = read 205 + .filter_map(Result::ok) 206 + .filter_map(|entry| { 207 + let path = entry.path(); 208 + let document_ron = path.join(bone_document::io::folder::DOCUMENT_FILE); 209 + let is_doc = path.is_dir() && document_ron.is_file(); 210 + if !is_doc { 211 + return None; 212 + } 213 + let label = path 214 + .file_name() 215 + .map(|s| s.to_string_lossy().into_owned()) 216 + .unwrap_or_default(); 217 + let id = WidgetId::ROOT 218 + .child(WidgetKey::new("app.file_picker.entry")) 219 + .child_named(WidgetKey::new("name"), &label); 220 + Some(PickerEntry { id, label, path }) 221 + }) 222 + .collect(); 223 + entries.sort_by(|a, b| a.label.cmp(&b.label)); 224 + Ok(entries) 225 + } 226 + 227 + #[must_use] 228 + pub fn documents_root() -> PathBuf { 229 + if let Some(path) = std::env::var_os("BONE_DOCUMENTS_DIR") { 230 + return PathBuf::from(path); 231 + } 232 + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); 233 + cwd.join(DEFAULT_DOCUMENTS_SUBDIR) 234 + } 235 + 236 + #[cfg(test)] 237 + mod tests { 238 + use super::{ 239 + FilePickerSession, PickerCommand, scan_document_folders, translate, validate_save_name, 240 + }; 241 + use bone_ui::widgets::{FilePickerMode, FilePickerOutcome}; 242 + use std::fs; 243 + use std::path::PathBuf; 244 + 245 + fn tmp(prefix: &str) -> PathBuf { 246 + let nonce = std::time::SystemTime::now() 247 + .duration_since(std::time::UNIX_EPOCH) 248 + .map(|d| d.as_nanos()) 249 + .unwrap_or_default(); 250 + let base = std::env::temp_dir().join(format!("bone-file-menu-{prefix}-{nonce}")); 251 + match fs::create_dir_all(&base) { 252 + Ok(()) => base, 253 + Err(e) => panic!("temp dir create failed: {e}"), 254 + } 255 + } 256 + 257 + fn write_stub(path: &std::path::Path, body: &str) { 258 + if let Some(parent) = path.parent() 259 + && let Err(e) = fs::create_dir_all(parent) 260 + { 261 + panic!("create parent {}: {e}", parent.display()); 262 + } 263 + if let Err(e) = fs::write(path, body) { 264 + panic!("write stub {}: {e}", path.display()); 265 + } 266 + } 267 + 268 + const RON_STUB: &str = "(schema:(name:\"bone-document\",version:(major:1,minor:0)))"; 269 + 270 + fn scan(root: &std::path::Path) -> Vec<super::PickerEntry> { 271 + match scan_document_folders(root) { 272 + Ok(v) => v, 273 + Err(e) => panic!("scan {}: {e}", root.display()), 274 + } 275 + } 276 + 277 + #[test] 278 + fn scan_picks_only_document_folders() { 279 + let root = tmp("scan"); 280 + write_stub(&root.join("doc_a/document.ron"), "stub"); 281 + write_stub(&root.join("doc_b/document.ron"), "stub"); 282 + write_stub(&root.join("not_a_doc/.keep"), ""); 283 + write_stub(&root.join("loose-file.txt"), "x"); 284 + let entries = scan(&root); 285 + assert_eq!(entries.len(), 2); 286 + let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect(); 287 + assert_eq!(labels, ["doc_a", "doc_b"]); 288 + } 289 + 290 + #[test] 291 + fn scan_missing_root_is_ok_empty() { 292 + let root = tmp("scan-missing").join("does_not_exist"); 293 + let entries = scan_document_folders(&root); 294 + assert!(matches!(entries, Ok(v) if v.is_empty())); 295 + } 296 + 297 + #[test] 298 + fn scan_ids_are_stable_across_reruns() { 299 + let root = tmp("scan-stable"); 300 + write_stub(&root.join("alpha/document.ron"), RON_STUB); 301 + write_stub(&root.join("beta/document.ron"), RON_STUB); 302 + let first = scan(&root); 303 + let second = scan(&root); 304 + assert_eq!( 305 + first.iter().map(|e| e.id).collect::<Vec<_>>(), 306 + second.iter().map(|e| e.id).collect::<Vec<_>>(), 307 + ); 308 + } 309 + 310 + #[test] 311 + fn open_session_seeds_filename() { 312 + let root = tmp("seed"); 313 + let session = FilePickerSession::open( 314 + root, 315 + FilePickerMode::Save, 316 + Some("Untitled".to_owned()), 317 + Vec::new(), 318 + ); 319 + assert_eq!(session.state.filename.text, "Untitled"); 320 + } 321 + 322 + #[test] 323 + fn translate_open_resolves_widget_id_to_path() { 324 + let root = tmp("translate-open"); 325 + write_stub(&root.join("alpha/document.ron"), RON_STUB); 326 + let entries = scan(&root); 327 + let cmd = translate( 328 + FilePickerOutcome::Open { 329 + folder: entries[0].id, 330 + }, 331 + &entries, 332 + &root, 333 + ); 334 + assert_eq!( 335 + cmd, 336 + Some(PickerCommand::OpenFolder(entries[0].path.clone())) 337 + ); 338 + } 339 + 340 + #[test] 341 + fn translate_save_uses_filename_field_under_root() { 342 + let root = tmp("translate-save"); 343 + let entries = scan(&root); 344 + let cmd = translate( 345 + FilePickerOutcome::Save { 346 + folder: None, 347 + filename: "brand_new".into(), 348 + }, 349 + &entries, 350 + &root, 351 + ); 352 + assert_eq!(cmd, Some(PickerCommand::SaveAs(root.join("brand_new")))); 353 + } 354 + 355 + #[test] 356 + fn translate_cancel_is_cancel() { 357 + let root = tmp("translate-cancel"); 358 + let entries = scan(&root); 359 + let cmd = translate(FilePickerOutcome::Cancelled, &entries, &root); 360 + assert_eq!(cmd, Some(PickerCommand::Cancel)); 361 + } 362 + 363 + #[test] 364 + fn translate_save_empty_filename_falls_back_to_selection() { 365 + let root = tmp("translate-save-fallback"); 366 + write_stub(&root.join("seed/document.ron"), RON_STUB); 367 + let entries = scan(&root); 368 + let cmd = translate( 369 + FilePickerOutcome::Save { 370 + folder: Some(entries[0].id), 371 + filename: String::new(), 372 + }, 373 + &entries, 374 + &root, 375 + ); 376 + assert_eq!(cmd, Some(PickerCommand::SaveAs(entries[0].path.clone()))); 377 + } 378 + 379 + #[test] 380 + fn translate_save_rejects_whitespace_only_filename() { 381 + let root = tmp("translate-save-whitespace"); 382 + write_stub(&root.join("seed/document.ron"), RON_STUB); 383 + let entries = scan(&root); 384 + let cmd = translate( 385 + FilePickerOutcome::Save { 386 + folder: Some(entries[0].id), 387 + filename: " ".into(), 388 + }, 389 + &entries, 390 + &root, 391 + ); 392 + assert_eq!(cmd, None); 393 + } 394 + 395 + #[test] 396 + fn validate_accepts_simple_name() { 397 + assert_eq!(validate_save_name("Untitled"), Some("Untitled")); 398 + assert_eq!(validate_save_name("doc_42"), Some("doc_42")); 399 + assert_eq!(validate_save_name("a-b.c"), Some("a-b.c")); 400 + } 401 + 402 + #[test] 403 + fn validate_rejects_path_escape_and_separators() { 404 + assert_eq!(validate_save_name(""), None); 405 + assert_eq!(validate_save_name(".."), None); 406 + assert_eq!(validate_save_name("."), None); 407 + assert_eq!(validate_save_name("../escape"), None); 408 + assert_eq!(validate_save_name("a/b"), None); 409 + #[cfg(windows)] 410 + assert_eq!(validate_save_name(r"a\b"), None); 411 + #[cfg(unix)] 412 + assert_eq!(validate_save_name("/abs/path"), None); 413 + } 414 + 415 + #[test] 416 + fn validate_rejects_windows_reserved_stems() { 417 + assert_eq!(validate_save_name("CON"), None); 418 + assert_eq!(validate_save_name("con"), None); 419 + assert_eq!(validate_save_name("NUL.txt"), None); 420 + assert_eq!(validate_save_name("COM1"), None); 421 + assert_eq!(validate_save_name("LPT9.bak"), None); 422 + assert_eq!(validate_save_name("AUX"), None); 423 + } 424 + 425 + #[test] 426 + fn validate_rejects_windows_reserved_chars() { 427 + assert_eq!(validate_save_name("foo<bar"), None); 428 + assert_eq!(validate_save_name("a:b"), None); 429 + assert_eq!(validate_save_name("pipe|name"), None); 430 + assert_eq!(validate_save_name("ask?"), None); 431 + assert_eq!(validate_save_name("star*"), None); 432 + assert_eq!(validate_save_name("quote\""), None); 433 + assert_eq!(validate_save_name("ctrl\x07char"), None); 434 + } 435 + 436 + #[test] 437 + fn validate_rejects_trailing_dot_or_space() { 438 + assert_eq!(validate_save_name("trailing."), None); 439 + assert_eq!(validate_save_name("trailing "), None); 440 + assert_eq!(validate_save_name(" leading"), None); 441 + assert_eq!(validate_save_name("middle.dots.ok"), Some("middle.dots.ok")); 442 + } 443 + 444 + #[test] 445 + fn validate_rejects_names_exceeding_byte_cap() { 446 + use super::MAX_SAVE_NAME_BYTES; 447 + let at_cap: String = "a".repeat(MAX_SAVE_NAME_BYTES); 448 + let over_cap: String = "a".repeat(MAX_SAVE_NAME_BYTES + 1); 449 + assert!(validate_save_name(&at_cap).is_some()); 450 + assert_eq!(validate_save_name(&over_cap), None); 451 + } 452 + 453 + #[test] 454 + fn translate_save_rejects_parent_traversal() { 455 + let root = tmp("translate-save-traversal"); 456 + let entries = scan(&root); 457 + let cmd = translate( 458 + FilePickerOutcome::Save { 459 + folder: None, 460 + filename: "../escape".into(), 461 + }, 462 + &entries, 463 + &root, 464 + ); 465 + assert_eq!(cmd, None); 466 + } 467 + 468 + #[test] 469 + fn translate_save_rejects_nested_path() { 470 + let root = tmp("translate-save-nested"); 471 + let entries = scan(&root); 472 + let cmd = translate( 473 + FilePickerOutcome::Save { 474 + folder: None, 475 + filename: "a/b".into(), 476 + }, 477 + &entries, 478 + &root, 479 + ); 480 + assert_eq!(cmd, None); 481 + } 482 + 483 + #[test] 484 + #[cfg(unix)] 485 + fn translate_save_rejects_absolute_path() { 486 + let root = tmp("translate-save-abs"); 487 + let entries = scan(&root); 488 + let cmd = translate( 489 + FilePickerOutcome::Save { 490 + folder: None, 491 + filename: "/etc/passwd".into(), 492 + }, 493 + &entries, 494 + &root, 495 + ); 496 + assert_eq!(cmd, None); 497 + } 498 + }
+783 -32
crates/bone-app/src/main.rs
··· 4 4 use std::sync::Arc; 5 5 6 6 use bone_document::{ 7 - DimensionKind, DimensionValue, Document, EditOutcome, Sketch, SketchDimension, SketchEdit, 8 - SketchEntity, SketchRelation, SolverError, UndoStack, 7 + DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, Sketch, SketchDimension, 8 + SketchEdit, SketchEntity, SketchRelation, SolverError, UndoStack, 9 9 }; 10 10 use bone_render::{ 11 11 Camera2, ChromeInstance, ChromePipeline, ChromeTextPipeline, PickQuery, PickedItem, ··· 43 43 mod chrome; 44 44 mod dimension_editor; 45 45 mod event; 46 + mod file_menu; 47 + mod native_picker; 46 48 mod redraw; 47 49 mod relation_tools; 48 50 mod selection; ··· 132 134 dim_editor: DimensionEditorState, 133 135 dim_editor_bounds: Option<LayoutRect>, 134 136 pending_exit: bool, 137 + current_folder: Option<DocumentFolder>, 138 + documents_root: PathBuf, 139 + file_picker: Option<file_menu::FilePickerSession>, 140 + native_picker: Option<native_picker::PendingHandle>, 141 + pending_overwrite: Option<DocumentFolder>, 142 + last_saved: Option<Document>, 143 + pending_discard: Option<PendingDiscard>, 144 + notification: Option<Notification>, 135 145 } 136 146 137 - #[derive(Default)] 147 + #[derive(Clone, Debug, PartialEq)] 148 + enum PendingDiscard { 149 + New, 150 + Open(PathBuf), 151 + } 152 + 153 + fn modal_active(state: &RenderState) -> bool { 154 + state.file_picker.is_some() 155 + || state.native_picker.is_some() 156 + || state.pending_overwrite.is_some() 157 + || state.pending_discard.is_some() 158 + } 159 + 160 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 161 + enum NotificationKind { 162 + Info, 163 + Error, 164 + } 165 + 166 + #[derive(Clone, Debug, PartialEq)] 167 + struct Notification { 168 + kind: NotificationKind, 169 + headline: bone_ui::strings::StringKey, 170 + detail: Option<String>, 171 + } 172 + 138 173 struct InputState { 139 174 cursor_px: Option<PhysicalPosition<f64>>, 140 175 left_pan: bool, ··· 144 179 pending_released: PointerButtonMask, 145 180 pending_keys: Vec<UiKeyEvent>, 146 181 pending_text: String, 182 + start: std::time::Instant, 183 + } 184 + 185 + impl Default for InputState { 186 + fn default() -> Self { 187 + Self { 188 + cursor_px: None, 189 + left_pan: false, 190 + middle_pan: false, 191 + modifiers: ModifiersState::empty(), 192 + pending_pressed: PointerButtonMask::EMPTY, 193 + pending_released: PointerButtonMask::EMPTY, 194 + pending_keys: Vec::new(), 195 + pending_text: String::new(), 196 + start: std::time::Instant::now(), 197 + } 198 + } 147 199 } 148 200 149 201 impl InputState { ··· 184 236 } 185 237 186 238 fn drain_snapshot(&mut self) -> InputSnapshot { 187 - let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 239 + let mut snap = InputSnapshot::idle(FrameInstant::from_duration(self.start.elapsed())); 188 240 snap.pointer = self.pointer_sample(); 189 241 snap.buttons_pressed = 190 242 core::mem::replace(&mut self.pending_pressed, PointerButtonMask::EMPTY); ··· 801 853 let theme = Arc::new(Theme::light()); 802 854 let shell = shell::Shell::new(); 803 855 let (document, sketch_id) = initial_document(sketch); 856 + let last_saved_baseline = document.clone(); 804 857 let plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); 805 858 let strings = strings::make_strings(bone_ui::strings::Locale::EnUs); 806 859 let viewport_rect = empty_rect(); ··· 837 890 dim_editor: DimensionEditorState::default(), 838 891 dim_editor_bounds: None, 839 892 pending_exit: false, 893 + current_folder: None, 894 + documents_root: file_menu::documents_root(), 895 + file_picker: None, 896 + native_picker: None, 897 + pending_overwrite: None, 898 + last_saved: Some(last_saved_baseline), 899 + pending_discard: None, 900 + notification: None, 840 901 }); 841 902 } 842 903 ··· 853 914 event::AppEvent::Ignored => {} 854 915 } 855 916 } 917 + 918 + fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: winit::event::StartCause) { 919 + if matches!(cause, winit::event::StartCause::ResumeTimeReached { .. }) 920 + && let Some(scheduler) = self.redraw.as_mut() 921 + { 922 + scheduler.kick(); 923 + scheduler.window().request_redraw(); 924 + } 925 + } 856 926 } 857 927 858 928 impl App { ··· 883 953 event::InputEvent::CursorMove(position) => { 884 954 let prev = self.input.cursor_px; 885 955 self.input.cursor_px = Some(position); 886 - if self.input.panning() 956 + let modal = modal_active(state); 957 + if !modal 958 + && self.input.panning() 887 959 && let Some(p) = prev 888 960 { 889 961 state.camera = pan_by_px(state.camera, position.x - p.x, position.y - p.y); 890 - } else if dragging_in_sketch(&state.mode) 962 + } else if !modal 963 + && dragging_in_sketch(&state.mode) 891 964 && let Some(world) = cursor_to_world(state.camera, position) 892 965 { 893 966 try_drag_to(state, world); ··· 897 970 self.input.cursor_px = None; 898 971 } 899 972 event::InputEvent::CursorEntered => {} 900 - event::InputEvent::Pointer { button, state: btn_state } => { 973 + event::InputEvent::Pointer { 974 + button, 975 + state: btn_state, 976 + } => { 901 977 self.dispatch_pointer_button(button, btn_state); 902 978 } 903 979 event::InputEvent::Wheel(delta) => { 904 - if self.input.cursor_in(state.viewport_rect) { 905 - state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); 980 + if !modal_active(state) && self.input.cursor_in(state.viewport_rect) { 981 + state.camera = 982 + zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta)); 906 983 } 907 984 } 908 985 event::InputEvent::KeyDown { ··· 917 994 } 918 995 919 996 fn dispatch_pointer_button(&mut self, button: MouseButton, btn_state: ElementState) { 920 - let Some(state) = self.render.as_mut() else { return }; 997 + let Some(state) = self.render.as_mut() else { 998 + return; 999 + }; 1000 + let modal = modal_active(state); 921 1001 match button { 922 1002 MouseButton::Left => { 923 1003 if btn_state == ElementState::Pressed { ··· 926 1006 .dim_editor_bounds 927 1007 .is_some_and(|r| self.input.cursor_in(r)); 928 1008 let dim_active = dim_flow_active(&state.mode); 929 - self.input.left_pan = in_viewport && !over_dim_editor && !dim_active; 1009 + self.input.left_pan = 1010 + !modal && in_viewport && !over_dim_editor && !dim_active; 930 1011 self.input.pending_pressed = 931 1012 self.input.pending_pressed.with(PointerButton::Primary); 932 - if in_viewport 1013 + if !modal 1014 + && in_viewport 933 1015 && !over_dim_editor 934 1016 && !dim_active 935 1017 && !self.input.modifiers.shift_key() ··· 956 1038 if btn_state == ElementState::Pressed { 957 1039 self.input.pending_pressed = 958 1040 self.input.pending_pressed.with(PointerButton::Secondary); 959 - if self.input.cursor_in(state.viewport_rect) { 1041 + if !modal && self.input.cursor_in(state.viewport_rect) { 960 1042 state.mode = core::mem::take(&mut state.mode).clear_pending(); 961 1043 } 962 1044 } else { ··· 966 1048 } 967 1049 MouseButton::Middle => { 968 1050 if btn_state == ElementState::Pressed { 969 - self.input.middle_pan = self.input.cursor_in(state.viewport_rect); 1051 + self.input.middle_pan = 1052 + !modal && self.input.cursor_in(state.viewport_rect); 970 1053 self.input.pending_pressed = 971 1054 self.input.pending_pressed.with(PointerButton::Middle); 972 1055 } else { ··· 986 1069 logical_key: &Key, 987 1070 text: Option<&winit::keyboard::SmolStr>, 988 1071 ) { 989 - let Some(state) = self.render.as_mut() else { return }; 1072 + let Some(state) = self.render.as_mut() else { 1073 + return; 1074 + }; 990 1075 let physical_code = match physical_key { 991 1076 PhysicalKey::Code(c) => Some(c), 992 1077 PhysicalKey::Unidentified(_) => None, ··· 1014 1099 self.input.pending_text.push_str(&filtered); 1015 1100 } 1016 1101 } 1017 - let suppress_camera = 1018 - dim_flow_active(&state.mode) || state.focus.focused().is_some(); 1102 + let suppress_camera = dim_flow_active(&state.mode) || state.focus.focused().is_some(); 1103 + if matches!(named, Some(NamedKey::Escape)) && state.notification.is_some() { 1104 + state.notification = None; 1105 + } 1019 1106 if let Some(code) = physical_code { 1020 1107 match keyboard_action(code, &self.input, state) { 1021 1108 Some(KeyAction::Exit) => event_loop.exit(), ··· 1034 1121 render_frame(state, scheduler, &mut self.input); 1035 1122 if state.pending_exit { 1036 1123 event_loop.exit(); 1124 + return; 1125 + } 1126 + if let Some(deadline) = next_wake_deadline(state, &self.input) { 1127 + event_loop.set_control_flow(ControlFlow::WaitUntil(deadline)); 1128 + } else { 1129 + event_loop.set_control_flow(ControlFlow::Wait); 1037 1130 } 1038 1131 } 1039 1132 } 1040 1133 1134 + fn next_wake_deadline(state: &RenderState, input: &InputState) -> Option<std::time::Instant> { 1135 + let now = std::time::Instant::now(); 1136 + let native_poll = state 1137 + .native_picker 1138 + .is_some() 1139 + .then(|| now + std::time::Duration::from_millis(40)); 1140 + let rename_deadline = state.shell.state.feature_tree.pending_rename.map(|pending| { 1141 + let window = bone_ui::input::DoubleClickWindow::DEFAULT.duration(); 1142 + let slack = std::time::Duration::from_millis(8); 1143 + input.start + pending.at.duration() + window + slack 1144 + }); 1145 + [native_poll, rename_deadline].into_iter().flatten().min() 1146 + } 1147 + 1041 1148 #[allow( 1042 1149 clippy::cast_precision_loss, 1043 1150 reason = "viewport pixel counts at any realistic display size fit f32 mantissa" ··· 1051 1158 scheduler: &mut redraw::Scheduler, 1052 1159 input_state: &mut InputState, 1053 1160 ) { 1161 + poll_native_picker(state); 1054 1162 let extent = state.surface.extent(); 1055 1163 let layout_size = layout_size_from_extent(extent); 1056 1164 let theme = Arc::clone(&state.theme); ··· 1062 1170 .cursor_px 1063 1171 .filter(|c| state.viewport_rect.contains(physical_to_layout_pos(*c))) 1064 1172 .and_then(|c| cursor_to_world(state.camera, c)); 1065 - let (mut frame, hotkey_actions, dim_outcome, conflict_outcome) = run_frame_ui( 1173 + let FrameOutcomes { 1174 + mut frame, 1175 + hotkey_actions, 1176 + dim: dim_outcome, 1177 + dim_conflict: conflict_outcome, 1178 + picker: picker_outcome, 1179 + overwrite: overwrite_outcome, 1180 + discard: discard_outcome, 1181 + notification: notification_outcome, 1182 + } = run_frame_ui( 1066 1183 state, 1067 1184 theme, 1068 1185 &mut input, ··· 1076 1193 &mut frame.overlay_paints, 1077 1194 dim_outcome.as_ref(), 1078 1195 conflict_outcome.as_ref(), 1196 + picker_outcome.as_ref(), 1197 + overwrite_outcome.as_ref(), 1198 + discard_outcome.as_ref(), 1199 + notification_outcome.as_ref(), 1079 1200 ); 1201 + if let Some(cmd) = picker_outcome.and_then(|o| o.command) { 1202 + apply_picker_command(state, cmd); 1203 + } 1204 + apply_overwrite_outcome(state, overwrite_outcome); 1205 + apply_discard_outcome(state, discard_outcome); 1206 + apply_notification_outcome(state, notification_outcome); 1080 1207 let claimed_pointer = dim_outcome.as_ref().is_some_and(|o| o.claimed_pointer); 1081 1208 let frame = if claimed_pointer { 1082 1209 suppress_pointer_activations(frame) ··· 1289 1416 } 1290 1417 } 1291 1418 1419 + #[allow( 1420 + clippy::too_many_arguments, 1421 + reason = "popup overlay dispatch threads every transient surface" 1422 + )] 1292 1423 fn apply_popup_overlays( 1293 1424 overlay: &mut Vec<bone_ui::widgets::WidgetPaint>, 1294 1425 dim_outcome: Option<&DimensionEditorOutcome>, 1295 1426 conflict_outcome: Option<&DimConflictOutcome>, 1427 + picker_outcome: Option<&file_menu::PickerModalOutcome>, 1428 + overwrite_outcome: Option<&OverwriteOutcome>, 1429 + discard_outcome: Option<&DiscardOutcome>, 1430 + notification_outcome: Option<&NotificationOutcome>, 1296 1431 ) -> Option<LayoutRect> { 1297 1432 let dim_closing = matches!( 1298 1433 dim_outcome.map(|o| &o.action), ··· 1312 1447 conflict_outcome.map(|o| o.paints.as_slice()), 1313 1448 conflict_closing, 1314 1449 ); 1450 + let picker_closing = picker_outcome.and_then(|o| o.command.as_ref()).is_some(); 1451 + extend_when_open( 1452 + overlay, 1453 + picker_outcome.map(|o| o.paints.as_slice()), 1454 + picker_closing, 1455 + ); 1456 + let overwrite_closing = matches!( 1457 + overwrite_outcome.map(|o| o.action), 1458 + Some(OverwriteAction::Replace | OverwriteAction::Cancel), 1459 + ); 1460 + extend_when_open( 1461 + overlay, 1462 + overwrite_outcome.map(|o| o.paints.as_slice()), 1463 + overwrite_closing, 1464 + ); 1465 + let discard_closing = matches!( 1466 + discard_outcome.map(|o| o.action), 1467 + Some(DiscardAction::Confirm | DiscardAction::Cancel), 1468 + ); 1469 + extend_when_open( 1470 + overlay, 1471 + discard_outcome.map(|o| o.paints.as_slice()), 1472 + discard_closing, 1473 + ); 1474 + if let Some(notification) = notification_outcome { 1475 + overlay.extend(notification.paints.iter().cloned()); 1476 + } 1315 1477 if dim_closing { 1316 1478 None 1317 1479 } else { ··· 1331 1493 } 1332 1494 } 1333 1495 1496 + struct FrameOutcomes { 1497 + frame: shell::ShellFrame, 1498 + hotkey_actions: Vec<ActionId>, 1499 + dim: Option<DimensionEditorOutcome>, 1500 + dim_conflict: Option<DimConflictOutcome>, 1501 + picker: Option<file_menu::PickerModalOutcome>, 1502 + overwrite: Option<OverwriteOutcome>, 1503 + discard: Option<DiscardOutcome>, 1504 + notification: Option<NotificationOutcome>, 1505 + } 1506 + 1334 1507 #[allow( 1335 1508 clippy::too_many_arguments, 1336 1509 reason = "run_frame_ui threads every per-frame UI subsystem" ··· 1344 1517 scopes: &HotkeyScopes, 1345 1518 layout_size: LayoutSize, 1346 1519 cursor_world: Option<Point2>, 1347 - ) -> ( 1348 - shell::ShellFrame, 1349 - Vec<ActionId>, 1350 - Option<DimensionEditorOutcome>, 1351 - Option<DimConflictOutcome>, 1352 - ) { 1520 + ) -> FrameOutcomes { 1353 1521 let mut ctx = FrameCtx::new( 1354 1522 theme, 1355 1523 input, ··· 1390 1558 }); 1391 1559 let conflict_outcome = 1392 1560 dim_conflict_pending(&state.mode).map(|_| render_dim_conflict_modal(&mut ctx, layout_size)); 1393 - let actions = if conflict_outcome.is_some() || dim_outcome.is_some() { 1561 + let picker_outcome = state 1562 + .file_picker 1563 + .as_mut() 1564 + .map(|session| file_menu::render(&mut ctx, session, layout_size)); 1565 + let overwrite_outcome = state 1566 + .pending_overwrite 1567 + .is_some() 1568 + .then(|| render_overwrite_modal(&mut ctx, layout_size)); 1569 + let discard_outcome = state 1570 + .pending_discard 1571 + .is_some() 1572 + .then(|| render_discard_modal(&mut ctx, layout_size)); 1573 + let notification_outcome = state 1574 + .notification 1575 + .as_ref() 1576 + .map(|notification| render_notification_toast(&mut ctx, layout_size, notification)); 1577 + let any_modal_open = conflict_outcome.is_some() 1578 + || dim_outcome.is_some() 1579 + || picker_outcome.is_some() 1580 + || overwrite_outcome.is_some() 1581 + || discard_outcome.is_some(); 1582 + let actions = if any_modal_open { 1394 1583 Vec::new() 1395 1584 } else { 1396 1585 ctx.dispatch_hotkeys(scopes) 1397 1586 }; 1398 - (frame, actions, dim_outcome, conflict_outcome) 1587 + FrameOutcomes { 1588 + frame, 1589 + hotkey_actions: actions, 1590 + dim: dim_outcome, 1591 + dim_conflict: conflict_outcome, 1592 + picker: picker_outcome, 1593 + overwrite: overwrite_outcome, 1594 + discard: discard_outcome, 1595 + notification: notification_outcome, 1596 + } 1399 1597 } 1400 1598 1401 1599 fn dim_conflict_pending(mode: &Mode) -> Option<PendingDimension> { ··· 1454 1652 } 1455 1653 } 1456 1654 1655 + #[derive(Clone, Debug, PartialEq)] 1656 + struct OverwriteOutcome { 1657 + paints: Vec<bone_ui::widgets::WidgetPaint>, 1658 + action: OverwriteAction, 1659 + } 1660 + 1661 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 1662 + enum OverwriteAction { 1663 + Idle, 1664 + Replace, 1665 + Cancel, 1666 + } 1667 + 1668 + fn render_overwrite_modal(ctx: &mut FrameCtx<'_>, layout_size: LayoutSize) -> OverwriteOutcome { 1669 + use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; 1670 + use bone_ui::{WidgetId, WidgetKey}; 1671 + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); 1672 + let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0)); 1673 + let id = WidgetId::ROOT.child(WidgetKey::new("file.overwrite")); 1674 + let response = show_confirmation( 1675 + ctx, 1676 + ConfirmationDialog { 1677 + id, 1678 + viewport, 1679 + size: dialog_size, 1680 + title: strings::FILE_OVERWRITE_TITLE, 1681 + message: strings::FILE_OVERWRITE_MESSAGE, 1682 + confirm_label: strings::FILE_OVERWRITE_REPLACE, 1683 + cancel_label: strings::FILE_OVERWRITE_CANCEL, 1684 + destructive: true, 1685 + }, 1686 + ); 1687 + let action = match response.outcome { 1688 + Some(ConfirmationOutcome::Confirm) => OverwriteAction::Replace, 1689 + Some(ConfirmationOutcome::Cancel) => OverwriteAction::Cancel, 1690 + None => OverwriteAction::Idle, 1691 + }; 1692 + OverwriteOutcome { 1693 + paints: response.paint, 1694 + action, 1695 + } 1696 + } 1697 + 1698 + #[derive(Clone, Debug, PartialEq)] 1699 + struct DiscardOutcome { 1700 + paints: Vec<bone_ui::widgets::WidgetPaint>, 1701 + action: DiscardAction, 1702 + } 1703 + 1704 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 1705 + enum DiscardAction { 1706 + Idle, 1707 + Confirm, 1708 + Cancel, 1709 + } 1710 + 1711 + fn render_discard_modal(ctx: &mut FrameCtx<'_>, layout_size: LayoutSize) -> DiscardOutcome { 1712 + use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation}; 1713 + use bone_ui::{WidgetId, WidgetKey}; 1714 + let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size); 1715 + let dialog_size = LayoutSize::new(LayoutPx::new(460.0), LayoutPx::new(190.0)); 1716 + let id = WidgetId::ROOT.child(WidgetKey::new("file.discard")); 1717 + let response = show_confirmation( 1718 + ctx, 1719 + ConfirmationDialog { 1720 + id, 1721 + viewport, 1722 + size: dialog_size, 1723 + title: strings::FILE_DISCARD_TITLE, 1724 + message: strings::FILE_DISCARD_MESSAGE, 1725 + confirm_label: strings::FILE_DISCARD_CONFIRM, 1726 + cancel_label: strings::FILE_DISCARD_CANCEL, 1727 + destructive: true, 1728 + }, 1729 + ); 1730 + let action = match response.outcome { 1731 + Some(ConfirmationOutcome::Confirm) => DiscardAction::Confirm, 1732 + Some(ConfirmationOutcome::Cancel) => DiscardAction::Cancel, 1733 + None => DiscardAction::Idle, 1734 + }; 1735 + DiscardOutcome { 1736 + paints: response.paint, 1737 + action, 1738 + } 1739 + } 1740 + 1741 + fn apply_discard_outcome(state: &mut RenderState, outcome: Option<DiscardOutcome>) { 1742 + let Some(outcome) = outcome else { return }; 1743 + match outcome.action { 1744 + DiscardAction::Idle => {} 1745 + DiscardAction::Cancel => { 1746 + state.pending_discard = None; 1747 + } 1748 + DiscardAction::Confirm => { 1749 + let Some(pending) = state.pending_discard.take() else { 1750 + return; 1751 + }; 1752 + match pending { 1753 + PendingDiscard::New => apply_new_document(state), 1754 + PendingDiscard::Open(path) => apply_open_folder(state, path), 1755 + } 1756 + } 1757 + } 1758 + } 1759 + 1760 + #[derive(Clone, Debug, PartialEq)] 1761 + struct NotificationOutcome { 1762 + paints: Vec<bone_ui::widgets::WidgetPaint>, 1763 + dismissed: bool, 1764 + } 1765 + 1766 + fn render_notification_toast( 1767 + ctx: &mut FrameCtx<'_>, 1768 + layout_size: LayoutSize, 1769 + notification: &Notification, 1770 + ) -> NotificationOutcome { 1771 + use bone_ui::widgets::{Button, ButtonVariant, WidgetPaint, show_button}; 1772 + use bone_ui::{WidgetId, WidgetKey}; 1773 + let theme = ctx.theme(); 1774 + let bg = match notification.kind { 1775 + NotificationKind::Info => theme.colors.surface(bone_ui::theme::SurfaceLevel::L2), 1776 + NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::SUBTLE_BG), 1777 + }; 1778 + let fg = match notification.kind { 1779 + NotificationKind::Info => theme.colors.text_primary(), 1780 + NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::TEXT_HIGH), 1781 + }; 1782 + let toast_width = layout_size.width.value().clamp(280.0, 420.0); 1783 + let toast_height = if notification.detail.is_some() { 1784 + 72.0 1785 + } else { 1786 + 44.0 1787 + }; 1788 + let margin = 24.0; 1789 + let toast_rect = LayoutRect::new( 1790 + LayoutPos::new( 1791 + LayoutPx::new(margin), 1792 + LayoutPx::new(layout_size.height.value() - toast_height - margin), 1793 + ), 1794 + LayoutSize::new(LayoutPx::new(toast_width), LayoutPx::new(toast_height)), 1795 + ); 1796 + let id = WidgetId::ROOT.child(WidgetKey::new("notification.toast")); 1797 + let mut paints = vec![WidgetPaint::Surface { 1798 + rect: toast_rect, 1799 + fill: bg, 1800 + border: Some(bone_ui::theme::Border { 1801 + width: bone_ui::theme::StrokeWidth::HAIRLINE, 1802 + color: theme.colors.neutral.step(bone_ui::theme::Step12::BORDER), 1803 + }), 1804 + radius: theme.radius.sm, 1805 + elevation: Some(theme.elevation.level1), 1806 + }]; 1807 + let headline_rect = LayoutRect::new( 1808 + LayoutPos::new( 1809 + LayoutPx::new(toast_rect.origin.x.value() + 16.0), 1810 + LayoutPx::new(toast_rect.origin.y.value() + 12.0), 1811 + ), 1812 + LayoutSize::new(LayoutPx::new(toast_width - 120.0), LayoutPx::new(20.0)), 1813 + ); 1814 + paints.push(WidgetPaint::Label { 1815 + rect: headline_rect, 1816 + text: bone_ui::widgets::LabelText::Key(notification.headline), 1817 + color: fg, 1818 + role: theme.typography.label, 1819 + }); 1820 + if let Some(detail) = &notification.detail { 1821 + let detail_rect = LayoutRect::new( 1822 + LayoutPos::new( 1823 + LayoutPx::new(toast_rect.origin.x.value() + 16.0), 1824 + LayoutPx::new(toast_rect.origin.y.value() + 36.0), 1825 + ), 1826 + LayoutSize::new(LayoutPx::new(toast_width - 32.0), LayoutPx::new(24.0)), 1827 + ); 1828 + paints.push(WidgetPaint::Label { 1829 + rect: detail_rect, 1830 + text: bone_ui::widgets::LabelText::Owned(detail.clone()), 1831 + color: theme.colors.text_secondary(), 1832 + role: theme.typography.caption, 1833 + }); 1834 + } 1835 + let dismiss_rect = LayoutRect::new( 1836 + LayoutPos::new( 1837 + LayoutPx::new(toast_rect.origin.x.value() + toast_width - 96.0), 1838 + LayoutPx::new(toast_rect.origin.y.value() + 8.0), 1839 + ), 1840 + LayoutSize::new(LayoutPx::new(84.0), LayoutPx::new(28.0)), 1841 + ); 1842 + let dismiss_id = id.child(WidgetKey::new("dismiss")); 1843 + let response = show_button( 1844 + ctx, 1845 + Button::new( 1846 + dismiss_id, 1847 + dismiss_rect, 1848 + strings::NOTIFY_DISMISS, 1849 + ButtonVariant::Secondary, 1850 + ), 1851 + ); 1852 + paints.extend(response.paint); 1853 + NotificationOutcome { 1854 + paints, 1855 + dismissed: response.activated, 1856 + } 1857 + } 1858 + 1859 + fn apply_notification_outcome(state: &mut RenderState, outcome: Option<NotificationOutcome>) { 1860 + let Some(outcome) = outcome else { return }; 1861 + if outcome.dismissed { 1862 + state.notification = None; 1863 + } 1864 + } 1865 + 1866 + fn apply_overwrite_outcome(state: &mut RenderState, outcome: Option<OverwriteOutcome>) { 1867 + let Some(outcome) = outcome else { return }; 1868 + match outcome.action { 1869 + OverwriteAction::Idle => {} 1870 + OverwriteAction::Cancel => { 1871 + state.pending_overwrite = None; 1872 + } 1873 + OverwriteAction::Replace => { 1874 + if let Some(folder) = state.pending_overwrite.take() { 1875 + perform_save_to(state, folder); 1876 + } 1877 + } 1878 + } 1879 + } 1880 + 1457 1881 fn pending_dim(mode: &Mode) -> Option<PendingDimension> { 1458 1882 match mode { 1459 1883 Mode::Sketch { session, .. } => match session.dim_flow { ··· 1583 2007 let (after_add, dim_id) = match sketch.apply(SketchEdit::AddDimension(driven_proto)) { 1584 2008 Ok((next, EditOutcome::Dimension(id))) => (next, id), 1585 2009 Ok(_) => { 1586 - tracing::warn!(?driven_proto, "add driven dimension produced unexpected outcome"); 2010 + tracing::warn!( 2011 + ?driven_proto, 2012 + "add driven dimension produced unexpected outcome" 2013 + ); 1587 2014 state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 1588 2015 return; 1589 2016 } ··· 1676 2103 Some(shell::MenuAction::OpenSettings) => { 1677 2104 state.shell.state.settings_dialog_open = true; 1678 2105 } 2106 + Some(shell::MenuAction::NewDocument) => { 2107 + request_new_document(state); 2108 + } 2109 + Some(shell::MenuAction::OpenDocument) => { 2110 + open_picker(state, bone_ui::widgets::FilePickerMode::Open, None); 2111 + } 2112 + Some(shell::MenuAction::SaveDocument) => { 2113 + apply_save_in_place(state); 2114 + } 2115 + Some(shell::MenuAction::SaveDocumentAs) => { 2116 + let seed = state.document.name().to_owned(); 2117 + open_picker(state, bone_ui::widgets::FilePickerMode::Save, Some(seed)); 2118 + } 1679 2119 Some(shell::MenuAction::Undo | shell::MenuAction::Redo | shell::MenuAction::ExitSketch) 1680 2120 | None => {} 1681 2121 } 1682 2122 } 1683 2123 2124 + fn request_new_document(state: &mut RenderState) { 2125 + if is_dirty(state) { 2126 + state.pending_discard = Some(PendingDiscard::New); 2127 + } else { 2128 + apply_new_document(state); 2129 + } 2130 + } 2131 + 2132 + fn request_open_folder(state: &mut RenderState, path: PathBuf) { 2133 + if is_dirty(state) { 2134 + state.pending_discard = Some(PendingDiscard::Open(path)); 2135 + } else { 2136 + apply_open_folder(state, path); 2137 + } 2138 + } 2139 + 2140 + fn is_dirty(state: &RenderState) -> bool { 2141 + state.last_saved.as_ref() != Some(&state.document) 2142 + } 2143 + 2144 + fn apply_new_document(state: &mut RenderState) { 2145 + let sketch = default_sketch(); 2146 + let scene = match SketchScene::extract(&sketch) { 2147 + Ok(s) => s, 2148 + Err(e) => { 2149 + tracing::warn!(error = %e, "scene extract failed on new document"); 2150 + notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); 2151 + return; 2152 + } 2153 + }; 2154 + let (document, sketch_id) = initial_document(sketch); 2155 + state.last_saved = Some(document.clone()); 2156 + state.document = document; 2157 + state.plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); 2158 + state.scene = scene; 2159 + state.mode = Mode::Idle; 2160 + state.selection = Selection::default(); 2161 + state.current_folder = None; 2162 + state.pending_overwrite = None; 2163 + let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { 2164 + unreachable!("UNDO_CAPACITY constant is non-zero"); 2165 + }; 2166 + state.undo = UndoStack::with_capacity(undo_capacity); 2167 + state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); 2168 + } 2169 + 2170 + fn open_picker( 2171 + state: &mut RenderState, 2172 + mode: bone_ui::widgets::FilePickerMode, 2173 + seed_filename: Option<String>, 2174 + ) { 2175 + if state.file_picker.is_some() || state.native_picker.is_some() { 2176 + return; 2177 + } 2178 + let starting_folder = state 2179 + .current_folder 2180 + .as_ref() 2181 + .map(|f| f.path().to_owned()) 2182 + .or_else(|| { 2183 + state 2184 + .documents_root 2185 + .is_dir() 2186 + .then(|| state.documents_root.clone()) 2187 + }); 2188 + let title_key = match mode { 2189 + bone_ui::widgets::FilePickerMode::Open => strings::FILE_PICKER_TITLE_OPEN, 2190 + bone_ui::widgets::FilePickerMode::Save => strings::FILE_PICKER_TITLE_SAVE_AS, 2191 + }; 2192 + let accept_key = match mode { 2193 + bone_ui::widgets::FilePickerMode::Open => strings::FILE_PICKER_OPEN, 2194 + bone_ui::widgets::FilePickerMode::Save => strings::FILE_PICKER_SAVE, 2195 + }; 2196 + let title = state.strings.resolve(title_key).to_owned(); 2197 + let accept_label = state.strings.resolve(accept_key).to_owned(); 2198 + let native_req = native_picker::Request { 2199 + mode, 2200 + title: title.as_str(), 2201 + accept_label: accept_label.as_str(), 2202 + seed_filename: seed_filename.as_deref(), 2203 + current_folder: starting_folder.as_deref(), 2204 + }; 2205 + match native_picker::spawn(native_req) { 2206 + Ok(handle) => { 2207 + state.native_picker = Some(handle); 2208 + return; 2209 + } 2210 + Err(native_picker::SpawnError::Unsupported) => { 2211 + tracing::debug!("native picker unavailable, falling back to custom picker"); 2212 + } 2213 + } 2214 + open_custom_picker(state, mode, seed_filename); 2215 + } 2216 + 2217 + fn open_custom_picker( 2218 + state: &mut RenderState, 2219 + mode: bone_ui::widgets::FilePickerMode, 2220 + seed_filename: Option<String>, 2221 + ) { 2222 + let entries = match file_menu::scan_document_folders(&state.documents_root) { 2223 + Ok(v) => v, 2224 + Err(e) => { 2225 + tracing::warn!(error = %e, path = %state.documents_root.display(), "scan documents root failed"); 2226 + notify_error(state, strings::NOTIFY_SCAN_FAILED, e.to_string()); 2227 + Vec::new() 2228 + } 2229 + }; 2230 + state.file_picker = Some(file_menu::FilePickerSession::open( 2231 + state.documents_root.clone(), 2232 + mode, 2233 + seed_filename, 2234 + entries, 2235 + )); 2236 + } 2237 + 2238 + fn poll_native_picker(state: &mut RenderState) { 2239 + let Some(handle) = state.native_picker.as_ref() else { 2240 + return; 2241 + }; 2242 + let outcome = match handle.poll() { 2243 + native_picker::PollState::Pending => return, 2244 + native_picker::PollState::Ready(o) => o, 2245 + }; 2246 + let mode = handle.mode; 2247 + state.native_picker = None; 2248 + match outcome { 2249 + native_picker::NativeOutcome::Path(path) => match mode { 2250 + bone_ui::widgets::FilePickerMode::Open => request_open_folder(state, path), 2251 + bone_ui::widgets::FilePickerMode::Save => apply_save_as(state, path), 2252 + }, 2253 + native_picker::NativeOutcome::Cancelled => {} 2254 + native_picker::NativeOutcome::Error(message) => { 2255 + tracing::warn!(error = %message, "native picker errored, falling back to custom picker"); 2256 + let seed = matches!(mode, bone_ui::widgets::FilePickerMode::Save) 2257 + .then(|| state.document.name().to_owned()); 2258 + open_custom_picker(state, mode, seed); 2259 + } 2260 + } 2261 + } 2262 + 2263 + fn apply_save_in_place(state: &mut RenderState) { 2264 + let Some(folder) = state.current_folder.clone() else { 2265 + let seed = state.document.name().to_owned(); 2266 + open_picker(state, bone_ui::widgets::FilePickerMode::Save, Some(seed)); 2267 + return; 2268 + }; 2269 + if let Err(e) = bone_document::save(&state.document, &folder) { 2270 + tracing::warn!(error = %e, path = %folder.path().display(), "save failed"); 2271 + notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string()); 2272 + return; 2273 + } 2274 + state.last_saved = Some(state.document.clone()); 2275 + notify_info(state, strings::NOTIFY_SAVED, None); 2276 + } 2277 + 2278 + fn apply_picker_command(state: &mut RenderState, command: file_menu::PickerCommand) { 2279 + state.file_picker = None; 2280 + match command { 2281 + file_menu::PickerCommand::Cancel => {} 2282 + file_menu::PickerCommand::OpenFolder(path) => request_open_folder(state, path), 2283 + file_menu::PickerCommand::SaveAs(path) => apply_save_as(state, path), 2284 + } 2285 + } 2286 + 2287 + fn apply_open_folder(state: &mut RenderState, path: PathBuf) { 2288 + let folder = DocumentFolder::new(path); 2289 + let document = match bone_document::load(&folder) { 2290 + Ok(d) => d, 2291 + Err(e) => { 2292 + tracing::warn!(error = %e, path = %folder.path().display(), "load failed"); 2293 + notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); 2294 + return; 2295 + } 2296 + }; 2297 + install_loaded_document(state, document, Some(folder)); 2298 + } 2299 + 2300 + fn apply_save_as(state: &mut RenderState, path: PathBuf) { 2301 + let folder = DocumentFolder::new(path); 2302 + let in_place = state 2303 + .current_folder 2304 + .as_ref() 2305 + .is_some_and(|current| same_folder(current.path(), folder.path())); 2306 + if folder.document_file().is_file() && !in_place { 2307 + state.pending_overwrite = Some(folder); 2308 + return; 2309 + } 2310 + perform_save_to(state, folder); 2311 + } 2312 + 2313 + fn perform_save_to(state: &mut RenderState, folder: DocumentFolder) { 2314 + let prior_name = state.document.name().to_owned(); 2315 + state 2316 + .document 2317 + .set_name(folder_display_name(&folder, &state.strings)); 2318 + if let Err(e) = bone_document::save(&state.document, &folder) { 2319 + tracing::warn!(error = %e, path = %folder.path().display(), "save as failed"); 2320 + state.document.set_name(prior_name); 2321 + notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string()); 2322 + return; 2323 + } 2324 + state.current_folder = Some(folder); 2325 + state.last_saved = Some(state.document.clone()); 2326 + notify_info(state, strings::NOTIFY_SAVED, None); 2327 + } 2328 + 2329 + fn folder_display_name(folder: &DocumentFolder, string_table: &StringTable) -> String { 2330 + folder.path().file_name().map_or_else( 2331 + || { 2332 + string_table 2333 + .resolve(strings::DEFAULT_DOCUMENT_NAME) 2334 + .to_owned() 2335 + }, 2336 + |s| s.to_string_lossy().into_owned(), 2337 + ) 2338 + } 2339 + 2340 + fn same_folder(a: &Path, b: &Path) -> bool { 2341 + match (resolve_path(a), resolve_path(b)) { 2342 + (Some(x), Some(y)) => x == y, 2343 + _ => false, 2344 + } 2345 + } 2346 + 2347 + fn resolve_path(path: &Path) -> Option<PathBuf> { 2348 + if let Ok(canon) = std::fs::canonicalize(path) { 2349 + return Some(canon); 2350 + } 2351 + let parent = path.parent()?; 2352 + let file_name = path.file_name()?; 2353 + let parent_canon = std::fs::canonicalize(parent).ok()?; 2354 + Some(parent_canon.join(file_name)) 2355 + } 2356 + 2357 + fn notify_error(state: &mut RenderState, headline: bone_ui::strings::StringKey, detail: String) { 2358 + state.notification = Some(Notification { 2359 + kind: NotificationKind::Error, 2360 + headline, 2361 + detail: Some(detail), 2362 + }); 2363 + } 2364 + 2365 + fn notify_info( 2366 + state: &mut RenderState, 2367 + headline: bone_ui::strings::StringKey, 2368 + detail: Option<String>, 2369 + ) { 2370 + state.notification = Some(Notification { 2371 + kind: NotificationKind::Info, 2372 + headline, 2373 + detail, 2374 + }); 2375 + } 2376 + 2377 + fn install_loaded_document( 2378 + state: &mut RenderState, 2379 + document: Document, 2380 + folder: Option<DocumentFolder>, 2381 + ) { 2382 + let plane_sketches = plane_sketches_from(&document); 2383 + let active_sketch_id = plane_sketches.get(&Plane::Xy).copied(); 2384 + state.last_saved = Some(document.clone()); 2385 + state.document = document; 2386 + state.plane_sketches = plane_sketches; 2387 + state.mode = Mode::Idle; 2388 + state.selection = Selection::default(); 2389 + state.current_folder = folder; 2390 + state.pending_overwrite = None; 2391 + let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else { 2392 + unreachable!("UNDO_CAPACITY constant is non-zero"); 2393 + }; 2394 + state.undo = UndoStack::with_capacity(undo_capacity); 2395 + let scene_attempt = active_sketch_id 2396 + .and_then(|id| state.document.sketch(id)) 2397 + .map(SketchScene::extract); 2398 + state.scene = match scene_attempt { 2399 + None => SketchScene::empty(), 2400 + Some(Ok(scene)) => scene, 2401 + Some(Err(e)) => { 2402 + tracing::warn!(error = %e, "scene extract on load failed"); 2403 + notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string()); 2404 + SketchScene::empty() 2405 + } 2406 + }; 2407 + state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); 2408 + } 2409 + 2410 + fn plane_sketches_from(document: &Document) -> BTreeMap<Plane, SketchId> { 2411 + document 2412 + .sketches() 2413 + .map(|(id, _)| (Plane::Xy, id)) 2414 + .take(1) 2415 + .collect() 2416 + } 2417 + 1684 2418 fn apply_settings_change(state: &mut RenderState, change: Option<settings::Settings>) { 1685 2419 if let Some(next) = change { 1686 2420 state.settings = next; ··· 2327 3061 apply_sketch_rename_into( 2328 3062 &mut document, 2329 3063 &mut undo, 2330 - shell::SketchRenameRequest { id, label: "Profile".to_owned() }, 3064 + shell::SketchRenameRequest { 3065 + id, 3066 + label: "Profile".to_owned(), 3067 + }, 2331 3068 ); 2332 3069 assert_eq!(document.sketch_label(id), Some("Profile")); 2333 - assert_eq!(undo.past_len(), 1, "successful rename records one undo snapshot"); 3070 + assert_eq!( 3071 + undo.past_len(), 3072 + 1, 3073 + "successful rename records one undo snapshot" 3074 + ); 2334 3075 } 2335 3076 2336 3077 #[test] ··· 2340 3081 apply_sketch_rename_into( 2341 3082 &mut document, 2342 3083 &mut undo, 2343 - shell::SketchRenameRequest { id, label: " ".to_owned() }, 3084 + shell::SketchRenameRequest { 3085 + id, 3086 + label: " ".to_owned(), 3087 + }, 2344 3088 ); 2345 3089 assert_eq!(document.sketch_label(id), Some("Sketch1")); 2346 3090 assert_eq!(undo.past_len(), 0); ··· 2353 3097 apply_sketch_rename_into( 2354 3098 &mut document, 2355 3099 &mut undo, 2356 - shell::SketchRenameRequest { id, label: " Sketch1 ".to_owned() }, 3100 + shell::SketchRenameRequest { 3101 + id, 3102 + label: " Sketch1 ".to_owned(), 3103 + }, 2357 3104 ); 2358 3105 assert_eq!(document.sketch_label(id), Some("Sketch1")); 2359 - assert_eq!(undo.past_len(), 0, "trimmed-equal rename must not record undo"); 3106 + assert_eq!( 3107 + undo.past_len(), 3108 + 0, 3109 + "trimmed-equal rename must not record undo" 3110 + ); 2360 3111 } 2361 3112 2362 3113 #[test]
+213
crates/bone-app/src/native_picker.rs
··· 1 + use std::path::PathBuf; 2 + use std::sync::mpsc::{Receiver, TryRecvError}; 3 + 4 + use bone_ui::widgets::FilePickerMode; 5 + 6 + #[derive(Debug)] 7 + pub enum NativeOutcome { 8 + Path(PathBuf), 9 + Cancelled, 10 + Error(String), 11 + } 12 + 13 + #[derive(Debug)] 14 + pub enum SpawnError { 15 + Unsupported, 16 + } 17 + 18 + pub struct PendingHandle { 19 + rx: Receiver<NativeOutcome>, 20 + pub mode: FilePickerMode, 21 + } 22 + 23 + impl PendingHandle { 24 + #[must_use] 25 + pub fn poll(&self) -> PollState { 26 + match self.rx.try_recv() { 27 + Ok(outcome) => PollState::Ready(outcome), 28 + Err(TryRecvError::Empty) => PollState::Pending, 29 + Err(TryRecvError::Disconnected) => { 30 + PollState::Ready(NativeOutcome::Error("picker worker disconnected".to_owned())) 31 + } 32 + } 33 + } 34 + } 35 + 36 + #[derive(Debug)] 37 + pub enum PollState { 38 + Pending, 39 + Ready(NativeOutcome), 40 + } 41 + 42 + #[derive(Copy, Clone)] 43 + pub struct Request<'a> { 44 + pub mode: FilePickerMode, 45 + pub title: &'a str, 46 + pub accept_label: &'a str, 47 + pub seed_filename: Option<&'a str>, 48 + pub current_folder: Option<&'a std::path::Path>, 49 + } 50 + 51 + #[cfg(target_os = "linux")] 52 + pub fn spawn(req: Request<'_>) -> Result<PendingHandle, SpawnError> { 53 + linux_impl::spawn(req) 54 + } 55 + 56 + #[cfg(not(target_os = "linux"))] 57 + pub fn spawn(_req: Request<'_>) -> Result<PendingHandle, SpawnError> { 58 + Err(SpawnError::Unsupported) 59 + } 60 + 61 + #[cfg(target_os = "linux")] 62 + mod linux_impl { 63 + use std::path::PathBuf; 64 + use std::sync::mpsc; 65 + 66 + use ashpd::desktop::file_chooser::{OpenFileRequest, SaveFileRequest}; 67 + use bone_ui::widgets::FilePickerMode; 68 + 69 + use super::{NativeOutcome, PendingHandle, Request, SpawnError}; 70 + 71 + pub(super) fn spawn(req: Request<'_>) -> Result<PendingHandle, SpawnError> { 72 + let mode = req.mode; 73 + let title = req.title.to_owned(); 74 + let accept = req.accept_label.to_owned(); 75 + let seed = req.seed_filename.map(str::to_owned); 76 + let folder = req.current_folder.map(PathBuf::from); 77 + let (tx, rx) = mpsc::channel(); 78 + let join = std::thread::Builder::new() 79 + .name("bone-native-picker".to_owned()) 80 + .spawn(move || { 81 + let outcome = pollster::block_on(run(mode, &title, &accept, seed, folder)); 82 + let _ = tx.send(outcome); 83 + }); 84 + match join { 85 + Ok(_handle) => Ok(PendingHandle { rx, mode }), 86 + Err(_) => Err(SpawnError::Unsupported), 87 + } 88 + } 89 + 90 + async fn run( 91 + mode: FilePickerMode, 92 + title: &str, 93 + accept: &str, 94 + seed: Option<String>, 95 + folder: Option<PathBuf>, 96 + ) -> NativeOutcome { 97 + match mode { 98 + FilePickerMode::Open => run_open(title, accept, folder).await, 99 + FilePickerMode::Save => run_save(title, accept, seed, folder).await, 100 + } 101 + } 102 + 103 + async fn run_open( 104 + title: &str, 105 + accept: &str, 106 + folder: Option<PathBuf>, 107 + ) -> NativeOutcome { 108 + let mut req = OpenFileRequest::default() 109 + .title(title) 110 + .accept_label(accept) 111 + .modal(true) 112 + .multiple(false) 113 + .directory(true); 114 + if let Some(f) = folder { 115 + req = match req.current_folder::<PathBuf>(Some(f)) { 116 + Ok(r) => r, 117 + Err(e) => return NativeOutcome::Error(e.to_string()), 118 + }; 119 + } 120 + let response = match req.send().await { 121 + Ok(r) => r, 122 + Err(e) => return classify_send_error(&e), 123 + }; 124 + let selected = match response.response() { 125 + Ok(s) => s, 126 + Err(e) => return classify_response_error(&e), 127 + }; 128 + match selected.uris().first().and_then(|u| uri_to_path(u.as_str())) { 129 + Some(path) => NativeOutcome::Path(path), 130 + None => NativeOutcome::Cancelled, 131 + } 132 + } 133 + 134 + async fn run_save( 135 + title: &str, 136 + accept: &str, 137 + seed: Option<String>, 138 + folder: Option<PathBuf>, 139 + ) -> NativeOutcome { 140 + let mut req = SaveFileRequest::default() 141 + .title(title) 142 + .accept_label(accept) 143 + .modal(true); 144 + if let Some(s) = seed.as_deref() { 145 + req = req.current_name(s); 146 + } 147 + if let Some(f) = folder { 148 + req = match req.current_folder::<PathBuf>(Some(f)) { 149 + Ok(r) => r, 150 + Err(e) => return NativeOutcome::Error(e.to_string()), 151 + }; 152 + } 153 + let response = match req.send().await { 154 + Ok(r) => r, 155 + Err(e) => return classify_send_error(&e), 156 + }; 157 + let selected = match response.response() { 158 + Ok(s) => s, 159 + Err(e) => return classify_response_error(&e), 160 + }; 161 + match selected.uris().first().and_then(|u| uri_to_path(u.as_str())) { 162 + Some(path) => NativeOutcome::Path(path), 163 + None => NativeOutcome::Cancelled, 164 + } 165 + } 166 + 167 + fn classify_send_error(e: &ashpd::Error) -> NativeOutcome { 168 + NativeOutcome::Error(format!("portal send: {e}")) 169 + } 170 + 171 + fn classify_response_error(e: &ashpd::Error) -> NativeOutcome { 172 + match e { 173 + ashpd::Error::Response(ashpd::desktop::ResponseError::Cancelled) => { 174 + NativeOutcome::Cancelled 175 + } 176 + other => NativeOutcome::Error(format!("portal response: {other}")), 177 + } 178 + } 179 + 180 + fn uri_to_path(uri: &str) -> Option<PathBuf> { 181 + let encoded = uri.strip_prefix("file://")?; 182 + let decoded = 183 + percent_encoding::percent_decode_str(encoded).decode_utf8_lossy(); 184 + Some(PathBuf::from(decoded.as_ref())) 185 + } 186 + 187 + #[cfg(test)] 188 + mod tests { 189 + use super::uri_to_path; 190 + use std::path::PathBuf; 191 + 192 + #[test] 193 + fn plain_path() { 194 + assert_eq!( 195 + uri_to_path("file:///home/nel/docs"), 196 + Some(PathBuf::from("/home/nel/docs")), 197 + ); 198 + } 199 + 200 + #[test] 201 + fn percent_decoded() { 202 + assert_eq!( 203 + uri_to_path("file:///home/nel/my%20docs"), 204 + Some(PathBuf::from("/home/nel/my docs")), 205 + ); 206 + } 207 + 208 + #[test] 209 + fn rejects_non_file_scheme() { 210 + assert_eq!(uri_to_path("http://example.com/docs"), None); 211 + } 212 + } 213 + }
+223 -88
crates/bone-app/src/shell.rs
··· 9 9 use bone_ui::a11y::{AccessNode, Role}; 10 10 use bone_ui::frame::{FrameCtx, InteractDeclaration}; 11 11 use bone_ui::hit_test::Sense; 12 - use bone_ui::widgets::GlyphMark; 13 12 use bone_ui::layout::{ 14 - Axis, DockNode, DockPanel, DockState, GridChild, GridLine, GridSpan, GridTrack, 15 - Layout, LayoutPos, LayoutPx, LayoutRect, LayoutSize, NodeKind, PanelId, RetainedLayout, 16 - SolvedLayout, SolvedNode, SplitFraction, TrackSize, measure, 13 + Axis, DockNode, DockPanel, DockState, GridChild, GridLine, GridSpan, GridTrack, Layout, 14 + LayoutPos, LayoutPx, LayoutRect, LayoutSize, NodeKind, PanelId, RetainedLayout, SolvedLayout, 15 + SolvedNode, SplitFraction, TrackSize, measure, 17 16 }; 18 17 use bone_ui::strings::{StringKey, StringTable}; 19 18 use bone_ui::theme::{Border, ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme}; 19 + use bone_ui::widgets::GlyphMark; 20 20 use bone_ui::widgets::{ 21 21 AngleEditor, Clipboard, Dialog, DialogButton, LabelText, LengthEditor, MemoryClipboard, 22 22 MenuBar, MenuBarEntry, MenuBarState, MenuItem, PropertyCell, PropertyEditor, PropertyGrid, ··· 93 93 menu_sketch: WidgetId, 94 94 menu_window: WidgetId, 95 95 menu_help: WidgetId, 96 + menu_file_new: WidgetId, 97 + menu_file_open: WidgetId, 98 + menu_file_save: WidgetId, 99 + menu_file_save_as: WidgetId, 96 100 menu_file_quit: WidgetId, 97 101 menu_edit_undo: WidgetId, 98 102 menu_edit_redo: WidgetId, ··· 151 155 menu_sketch, 152 156 menu_window: menu_bar.child(WidgetKey::new("window")), 153 157 menu_help: menu_bar.child(WidgetKey::new("help")), 158 + menu_file_new: menu_file.child(WidgetKey::new("new")), 159 + menu_file_open: menu_file.child(WidgetKey::new("open")), 160 + menu_file_save: menu_file.child(WidgetKey::new("save")), 161 + menu_file_save_as: menu_file.child(WidgetKey::new("save_as")), 154 162 menu_file_quit: menu_file.child(WidgetKey::new("quit")), 155 163 menu_edit_undo: menu_edit.child(WidgetKey::new("undo")), 156 164 menu_edit_redo: menu_edit.child(WidgetKey::new("redo")), ··· 177 185 178 186 fn menu_action_for(&self, id: WidgetId) -> Option<MenuAction> { 179 187 [ 188 + (self.menu_file_new, MenuAction::NewDocument), 189 + (self.menu_file_open, MenuAction::OpenDocument), 190 + (self.menu_file_save, MenuAction::SaveDocument), 191 + (self.menu_file_save_as, MenuAction::SaveDocumentAs), 180 192 (self.menu_file_quit, MenuAction::Quit), 181 193 (self.menu_edit_undo, MenuAction::Undo), 182 194 (self.menu_edit_redo, MenuAction::Redo), ··· 192 204 193 205 #[derive(Copy, Clone, Debug, PartialEq, Eq)] 194 206 pub enum MenuAction { 207 + NewDocument, 208 + OpenDocument, 209 + SaveDocument, 210 + SaveDocumentAs, 195 211 Quit, 196 212 Undo, 197 213 Redo, ··· 303 319 ], 304 320 gap, 305 321 ); 306 - chrome_grid( 307 - ChromeRows { 308 - menu: Layout::leaf(self.ids.menu_bar), 309 - ribbon: Layout::leaf(self.ids.ribbon), 310 - center, 311 - doc_tabs: Layout::leaf(self.ids.doc_tabs), 312 - status: Layout::leaf(self.ids.status_bar), 313 - }, 314 - ) 322 + chrome_grid(ChromeRows { 323 + menu: Layout::leaf(self.ids.menu_bar), 324 + ribbon: Layout::leaf(self.ids.ribbon), 325 + center, 326 + doc_tabs: Layout::leaf(self.ids.doc_tabs), 327 + status: Layout::leaf(self.ids.status_bar), 328 + }) 315 329 } 316 330 317 331 #[must_use] ··· 362 376 let menu_bar_rect = leaf_rect(&solved, self.ids.menu_bar).unwrap_or_else(zero_rect); 363 377 let left_pane_rect = panel_rect(&solved, self.panels.left_pane) 364 378 .map_or_else(zero_rect, |r| inset_rect(r, inset_px)); 365 - let LeftPaneSplit { tab_strip_rect, content_rect } = split_left_pane(left_pane_rect); 379 + let LeftPaneSplit { 380 + tab_strip_rect, 381 + content_rect, 382 + } = split_left_pane(left_pane_rect); 366 383 let status_rect = leaf_rect(&solved, self.ids.status_bar).unwrap_or_else(zero_rect); 367 384 let doc_tabs_rect = leaf_rect(&solved, self.ids.doc_tabs).unwrap_or_else(zero_rect); 368 385 let mut popover_paints: Vec<WidgetPaint> = Vec::new(); ··· 442 459 cursor_world, 443 460 &mut paints, 444 461 ); 445 - let confirm = render_confirm_corner( 446 - ctx, 447 - viewport_rect, 448 - &self.ids, 449 - mode.is_sketch(), 450 - &mut paints, 451 - ); 462 + let confirm = 463 + render_confirm_corner(ctx, viewport_rect, &self.ids, mode.is_sketch(), &mut paints); 452 464 let confirm_action = confirm; 453 - let exit_sketch = confirm_action.is_some() 454 - || menu_action == Some(MenuAction::ExitSketch); 465 + let exit_sketch = confirm_action.is_some() || menu_action == Some(MenuAction::ExitSketch); 455 466 let activated_tool = activated_widget.and_then(|id| self.tool_index.get(&id).copied()); 456 467 let activated_relation = resolve_activated_relation( 457 468 activated_widget, ··· 822 833 id: file, 823 834 label: strings::MENU_FILE, 824 835 items: vec![ 825 - action(file.child(WidgetKey::new("new")), strings::MENU_FILE_NEW, None, true), 826 - action( 827 - file.child(WidgetKey::new("open")), 828 - strings::MENU_FILE_OPEN, 829 - None, 830 - true, 831 - ), 836 + action(ids.menu_file_new, strings::MENU_FILE_NEW, None, false), 837 + action(ids.menu_file_open, strings::MENU_FILE_OPEN, None, false), 838 + action(ids.menu_file_save, strings::MENU_FILE_SAVE, None, false), 832 839 action( 833 - file.child(WidgetKey::new("save")), 834 - strings::MENU_FILE_SAVE, 840 + ids.menu_file_save_as, 841 + strings::MENU_FILE_SAVE_AS, 835 842 None, 836 - true, 843 + false, 837 844 ), 838 845 MenuItem::Separator, 839 846 action( ··· 1055 1062 let leaf = |key: &'static str, label: StringKey| { 1056 1063 TreeNode::leaf(part_id.child(WidgetKey::new(key)), label) 1057 1064 }; 1058 - let feature_leaf = |key: &'static str, label: StringKey| { 1059 - leaf(key, label).with_glyph(GlyphMark::TreeFeature) 1060 - }; 1061 - let placeholder = |key: &'static str, label: StringKey| { 1062 - feature_leaf(key, label).disabled(true) 1063 - }; 1064 - let plane_leaf = |key: &'static str, label: StringKey| { 1065 - leaf(key, label).with_glyph(GlyphMark::TreePlane) 1066 - }; 1065 + let feature_leaf = 1066 + |key: &'static str, label: StringKey| leaf(key, label).with_glyph(GlyphMark::TreeFeature); 1067 + let placeholder = |key: &'static str, label: StringKey| feature_leaf(key, label).disabled(true); 1068 + let plane_leaf = 1069 + |key: &'static str, label: StringKey| leaf(key, label).with_glyph(GlyphMark::TreePlane); 1067 1070 let sketch_rows: Vec<(SketchId, WidgetId, TreeNode)> = document 1068 1071 .sketches() 1069 1072 .map(|(sketch_id, _)| { 1070 1073 let widget_id = sketch_widget_id(part_id, sketch_id); 1071 - let label = document 1072 - .sketch_label(sketch_id) 1073 - .unwrap_or("") 1074 - .to_owned(); 1075 - let node = TreeNode::leaf_owned(widget_id, label) 1076 - .with_glyph(GlyphMark::TreeSketch); 1074 + let label = document.sketch_label(sketch_id).unwrap_or("").to_owned(); 1075 + let node = TreeNode::leaf_owned(widget_id, label).with_glyph(GlyphMark::TreeSketch); 1077 1076 (sketch_id, widget_id, node) 1078 1077 }) 1079 1078 .collect(); 1080 1079 let renamable: Vec<WidgetId> = sketch_rows.iter().map(|(_, w, _)| *w).collect(); 1081 - let widget_to_sketch: BTreeMap<WidgetId, SketchId> = sketch_rows 1082 - .iter() 1083 - .map(|(s, w, _)| (*w, *s)) 1084 - .collect(); 1080 + let widget_to_sketch: BTreeMap<WidgetId, SketchId> = 1081 + sketch_rows.iter().map(|(s, w, _)| (*w, *s)).collect(); 1085 1082 let children: Vec<TreeNode> = [ 1086 1083 placeholder("history", strings::FEATURE_HISTORY), 1087 1084 placeholder("sensors", strings::FEATURE_SENSORS), ··· 1113 1110 widget_to_sketch 1114 1111 .get(&id) 1115 1112 .copied() 1116 - .map(|sketch_id| SketchRenameRequest { id: sketch_id, label: text }) 1113 + .map(|sketch_id| SketchRenameRequest { 1114 + id: sketch_id, 1115 + label: text, 1116 + }) 1117 1117 }); 1118 1118 FeatureTreeOutcome { 1119 1119 double_activated: response.double_activated, ··· 1470 1470 Some(SketchEntity::Line(_)) => strings_table 1471 1471 .resolve(strings::PROPERTY_KIND_LINE) 1472 1472 .to_owned(), 1473 - Some(SketchEntity::Arc(_)) => strings_table 1474 - .resolve(strings::PROPERTY_KIND_ARC) 1475 - .to_owned(), 1473 + Some(SketchEntity::Arc(_)) => strings_table.resolve(strings::PROPERTY_KIND_ARC).to_owned(), 1476 1474 Some(SketchEntity::Circle(_)) => strings_table 1477 1475 .resolve(strings::PROPERTY_KIND_CIRCLE) 1478 1476 .to_owned(), ··· 1636 1634 rect.origin, 1637 1635 LayoutSize::new(LayoutPx::new(DOC_TAB_WIDTH_PX), rect.size.height), 1638 1636 ); 1639 - let tabs = [Tab::new(ids.doc_tab_model, tab_rect, strings::DOC_TAB_MODEL)]; 1637 + let tabs = [Tab::new( 1638 + ids.doc_tab_model, 1639 + tab_rect, 1640 + strings::DOC_TAB_MODEL, 1641 + )]; 1640 1642 let response = show_tabs( 1641 1643 ctx, 1642 1644 Tabs::new( ··· 2029 2031 2030 2032 fn split_left_pane(rect: LayoutRect) -> LeftPaneSplit { 2031 2033 let strip_height = LayoutPx::new(LEFT_PANE_TAB_STRIP_HEIGHT.min(rect.size.height.value())); 2032 - let tab_strip_rect = LayoutRect::new( 2033 - rect.origin, 2034 - LayoutSize::new(rect.size.width, strip_height), 2035 - ); 2034 + let tab_strip_rect = 2035 + LayoutRect::new(rect.origin, LayoutSize::new(rect.size.width, strip_height)); 2036 2036 let content_rect = LayoutRect::new( 2037 2037 LayoutPos::new( 2038 2038 rect.origin.x, ··· 2185 2185 let accept_x = cancel_x - CONFIRM_BUTTON_GAP - CONFIRM_BUTTON_PX; 2186 2186 let accept_rect = LayoutRect::new( 2187 2187 LayoutPos::new(LayoutPx::new(accept_x), LayoutPx::new(top_y)), 2188 - LayoutSize::new(LayoutPx::new(CONFIRM_BUTTON_PX), LayoutPx::new(CONFIRM_BUTTON_PX)), 2188 + LayoutSize::new( 2189 + LayoutPx::new(CONFIRM_BUTTON_PX), 2190 + LayoutPx::new(CONFIRM_BUTTON_PX), 2191 + ), 2189 2192 ); 2190 2193 let cancel_rect = LayoutRect::new( 2191 2194 LayoutPos::new(LayoutPx::new(cancel_x), LayoutPx::new(top_y)), 2192 - LayoutSize::new(LayoutPx::new(CONFIRM_BUTTON_PX), LayoutPx::new(CONFIRM_BUTTON_PX)), 2195 + LayoutSize::new( 2196 + LayoutPx::new(CONFIRM_BUTTON_PX), 2197 + LayoutPx::new(CONFIRM_BUTTON_PX), 2198 + ), 2193 2199 ); 2194 2200 let accept_clicked = paint_confirm_button( 2195 2201 ctx, ··· 2244 2250 ConfirmTone::Cancel => theme.colors.danger, 2245 2251 }; 2246 2252 let (fill, glyph_color) = if interaction.pressed() { 2247 - (palette.step(Step12::SELECTED_BG), palette.step(Step12::HOVER_SOLID)) 2253 + ( 2254 + palette.step(Step12::SELECTED_BG), 2255 + palette.step(Step12::HOVER_SOLID), 2256 + ) 2248 2257 } else if interaction.hover() { 2249 2258 (palette.step(Step12::HOVER_BG), palette.step(Step12::SOLID)) 2250 2259 } else { ··· 2343 2352 &mut a11y, 2344 2353 &mut shaper, 2345 2354 ); 2346 - shell.render(&mut ctx, document, mode, selection, Settings::default(), size, None) 2355 + shell.render( 2356 + &mut ctx, 2357 + document, 2358 + mode, 2359 + selection, 2360 + Settings::default(), 2361 + size, 2362 + None, 2363 + ) 2347 2364 } 2348 2365 2349 2366 #[test] ··· 2356 2373 } 2357 2374 2358 2375 #[test] 2376 + fn file_menu_ids_map_to_file_actions() { 2377 + let shell = Shell::new(); 2378 + assert_eq!( 2379 + shell.ids.menu_action_for(shell.ids.menu_file_new), 2380 + Some(MenuAction::NewDocument), 2381 + ); 2382 + assert_eq!( 2383 + shell.ids.menu_action_for(shell.ids.menu_file_open), 2384 + Some(MenuAction::OpenDocument), 2385 + ); 2386 + assert_eq!( 2387 + shell.ids.menu_action_for(shell.ids.menu_file_save), 2388 + Some(MenuAction::SaveDocument), 2389 + ); 2390 + assert_eq!( 2391 + shell.ids.menu_action_for(shell.ids.menu_file_save_as), 2392 + Some(MenuAction::SaveDocumentAs), 2393 + ); 2394 + } 2395 + 2396 + #[test] 2397 + fn file_menu_actions_are_enabled() { 2398 + let entries = build_menu_entries(&ShellIds::standard(), false); 2399 + let Some(file_menu) = entries.iter().find(|e| e.label == strings::MENU_FILE) else { 2400 + panic!("file menu entry missing"); 2401 + }; 2402 + let actions: Vec<(StringKey, bool)> = file_menu 2403 + .items 2404 + .iter() 2405 + .filter_map(|i| match i { 2406 + MenuItem::Action { 2407 + label, disabled, .. 2408 + } => Some((*label, *disabled)), 2409 + _ => None, 2410 + }) 2411 + .collect(); 2412 + let entry_for = |key: StringKey| { 2413 + actions 2414 + .iter() 2415 + .find(|(l, _)| *l == key) 2416 + .copied() 2417 + .unwrap_or((key, true)) 2418 + }; 2419 + assert!(!entry_for(strings::MENU_FILE_NEW).1); 2420 + assert!(!entry_for(strings::MENU_FILE_OPEN).1); 2421 + assert!(!entry_for(strings::MENU_FILE_SAVE).1); 2422 + assert!(!entry_for(strings::MENU_FILE_SAVE_AS).1); 2423 + } 2424 + 2425 + #[test] 2359 2426 fn settings_dialog_does_not_render_when_closed() { 2360 2427 let frame = render_with( 2361 2428 Theme::light(), ··· 2441 2508 assert!(v.size.height.value() > 0.0); 2442 2509 assert!(v.min_x().value() > 0.0, "left pane carved on left"); 2443 2510 assert!(v.min_y().value() > 0.0, "ribbon carved on top"); 2444 - assert!(v.max_x().value() <= 1280.0, "viewport bounded by window width"); 2511 + assert!( 2512 + v.max_x().value() <= 1280.0, 2513 + "viewport bounded by window width" 2514 + ); 2445 2515 assert!(v.max_y().value() < 800.0, "status bar carved on bottom"); 2446 2516 } 2447 2517 ··· 2859 2929 (sketch, line) 2860 2930 } 2861 2931 2862 - fn sketch_with_dim( 2863 - kind: DimensionKind, 2864 - ) -> (bone_document::Sketch, SketchDimensionId) { 2932 + fn sketch_with_dim(kind: DimensionKind) -> (bone_document::Sketch, SketchDimensionId) { 2865 2933 use bone_document::{EditOutcome, SketchEdit}; 2866 2934 use bone_types::Point2; 2867 2935 use uom::si::length::millimeter; ··· 2880 2948 (s, id) 2881 2949 } 2882 2950 2883 - fn sketch_with_relation() -> (bone_document::Sketch, bone_types::SketchRelationId, SketchEntityId) { 2951 + fn sketch_with_relation() -> ( 2952 + bone_document::Sketch, 2953 + bone_types::SketchRelationId, 2954 + SketchEntityId, 2955 + ) { 2884 2956 use bone_document::{EditOutcome, SketchEdit}; 2885 2957 use bone_types::Point2; 2886 2958 let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); ··· 2915 2987 &Mode::enter_sketch(sketch_id), 2916 2988 &Selection::Dimension(dim_id), 2917 2989 ); 2918 - let Some(DimPropertyEditor::Length { id, editor, .. }) = &shell.state.dim_property 2919 - else { 2990 + let Some(DimPropertyEditor::Length { id, editor, .. }) = &shell.state.dim_property else { 2920 2991 panic!("expected length editor populated"); 2921 2992 }; 2922 2993 assert_eq!(*id, dim_id); ··· 3046 3117 &Selection::Relation(rel_id), 3047 3118 ); 3048 3119 let any_horizontal_label = frame.paints.iter().any(|p| match p { 3049 - WidgetPaint::Label { text: LabelText::Owned(text), .. } => { 3050 - text == StringTable::empty().resolve(strings::TOOL_HORIZONTAL) 3051 - } 3120 + WidgetPaint::Label { 3121 + text: LabelText::Owned(text), 3122 + .. 3123 + } => text == StringTable::empty().resolve(strings::TOOL_HORIZONTAL), 3052 3124 _ => false, 3053 3125 }); 3054 3126 assert!(any_horizontal_label, "relation kind label should appear"); 3055 - assert!(shell.state.dim_property.is_none(), "relation does not own dim editor"); 3127 + assert!( 3128 + shell.state.dim_property.is_none(), 3129 + "relation does not own dim editor" 3130 + ); 3056 3131 } 3057 3132 3058 3133 fn shell_drive( ··· 3242 3317 3243 3318 #[test] 3244 3319 fn click_on_sketch_row_then_f2_enters_rename_via_full_shell() { 3245 - use bone_ui::input::{KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, PointerButtonMask, PointerSample}; 3320 + use bone_ui::input::{ 3321 + KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, PointerButtonMask, 3322 + PointerSample, 3323 + }; 3246 3324 let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis()); 3247 3325 let (document, sketch_id) = document_with_sketch(sketch); 3248 3326 let mut shell = Shell::new(); ··· 3276 3354 let mut press = InputSnapshot::idle(FrameInstant::ZERO); 3277 3355 press.pointer = Some(PointerSample::new(center)); 3278 3356 press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 3279 - let _ = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, press); 3357 + let _ = drive_with_snap( 3358 + &mut shell, 3359 + &document, 3360 + &Mode::Idle, 3361 + &Selection::default(), 3362 + &mut focus, 3363 + &mut prev, 3364 + press, 3365 + ); 3280 3366 3281 3367 let mut release = InputSnapshot::idle(FrameInstant::ZERO); 3282 3368 release.pointer = Some(PointerSample::new(center)); 3283 3369 release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 3284 - let _ = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, release); 3370 + let _ = drive_with_snap( 3371 + &mut shell, 3372 + &document, 3373 + &Mode::Idle, 3374 + &Selection::default(), 3375 + &mut focus, 3376 + &mut prev, 3377 + release, 3378 + ); 3285 3379 3286 3380 let mut idle = InputSnapshot::idle(FrameInstant::ZERO); 3287 3381 idle.pointer = Some(PointerSample::new(center)); 3288 - let _ = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, idle); 3382 + let _ = drive_with_snap( 3383 + &mut shell, 3384 + &document, 3385 + &Mode::Idle, 3386 + &Selection::default(), 3387 + &mut focus, 3388 + &mut prev, 3389 + idle, 3390 + ); 3289 3391 3290 3392 assert_eq!( 3291 3393 focus.focused(), ··· 3295 3397 3296 3398 let mut f2 = InputSnapshot::idle(FrameInstant::ZERO); 3297 3399 f2.pointer = Some(PointerSample::new(center)); 3298 - f2.keys_pressed.push(KeyEvent::new(KeyCode::Named(NamedKey::F2), ModifierMask::NONE)); 3299 - let _ = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, f2); 3400 + f2.keys_pressed.push(KeyEvent::new( 3401 + KeyCode::Named(NamedKey::F2), 3402 + ModifierMask::NONE, 3403 + )); 3404 + let _ = drive_with_snap( 3405 + &mut shell, 3406 + &document, 3407 + &Mode::Idle, 3408 + &Selection::default(), 3409 + &mut focus, 3410 + &mut prev, 3411 + f2, 3412 + ); 3300 3413 assert_eq!( 3301 3414 shell.state.feature_tree.renaming, 3302 3415 Some(widget), ··· 3337 3450 LayoutPx::new(row_rect.origin.y.value() + row_rect.size.height.value() / 2.0), 3338 3451 ); 3339 3452 3340 - let click = |shell: &mut Shell, 3341 - focus: &mut FocusManager, 3342 - prev: &mut HitState| { 3453 + let click = |shell: &mut Shell, focus: &mut FocusManager, prev: &mut HitState| { 3343 3454 let mut press = InputSnapshot::idle(FrameInstant::ZERO); 3344 3455 press.pointer = Some(PointerSample::new(center)); 3345 3456 press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 3346 - let _ = drive_with_snap(shell, &document, &Mode::Idle, &Selection::default(), focus, prev, press); 3457 + let _ = drive_with_snap( 3458 + shell, 3459 + &document, 3460 + &Mode::Idle, 3461 + &Selection::default(), 3462 + focus, 3463 + prev, 3464 + press, 3465 + ); 3347 3466 let mut release = InputSnapshot::idle(FrameInstant::ZERO); 3348 3467 release.pointer = Some(PointerSample::new(center)); 3349 3468 release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 3350 - drive_with_snap(shell, &document, &Mode::Idle, &Selection::default(), focus, prev, release) 3469 + drive_with_snap( 3470 + shell, 3471 + &document, 3472 + &Mode::Idle, 3473 + &Selection::default(), 3474 + focus, 3475 + prev, 3476 + release, 3477 + ) 3351 3478 }; 3352 3479 3353 3480 let _ = click(&mut shell, &mut focus, &mut prev); 3354 3481 let _ = click(&mut shell, &mut focus, &mut prev); 3355 3482 let mut idle = InputSnapshot::idle(FrameInstant::ZERO); 3356 3483 idle.pointer = Some(PointerSample::new(center)); 3357 - let (frame, _) = drive_with_snap(&mut shell, &document, &Mode::Idle, &Selection::default(), &mut focus, &mut prev, idle); 3484 + let (frame, _) = drive_with_snap( 3485 + &mut shell, 3486 + &document, 3487 + &Mode::Idle, 3488 + &Selection::default(), 3489 + &mut focus, 3490 + &mut prev, 3491 + idle, 3492 + ); 3358 3493 assert_eq!( 3359 3494 frame.sketch_activated, 3360 3495 Some(sketch_id),
+88 -4
crates/bone-app/src/strings.rs
··· 100 100 pub const MENU_FILE_NEW: StringKey = StringKey::new("menu.file.new"); 101 101 pub const MENU_FILE_OPEN: StringKey = StringKey::new("menu.file.open"); 102 102 pub const MENU_FILE_SAVE: StringKey = StringKey::new("menu.file.save"); 103 + pub const MENU_FILE_SAVE_AS: StringKey = StringKey::new("menu.file.save_as"); 103 104 pub const MENU_FILE_QUIT: StringKey = StringKey::new("menu.file.quit"); 105 + pub const FILE_PICKER_TITLE_OPEN: StringKey = StringKey::new("file_picker.title.open"); 106 + pub const FILE_PICKER_TITLE_SAVE_AS: StringKey = StringKey::new("file_picker.title.save_as"); 107 + pub const FILE_PICKER_OPEN: StringKey = StringKey::new("file_picker.open"); 108 + pub const FILE_PICKER_SAVE: StringKey = StringKey::new("file_picker.save"); 109 + pub const FILE_PICKER_CANCEL: StringKey = StringKey::new("file_picker.cancel"); 110 + pub const FILE_PICKER_LIST: StringKey = StringKey::new("file_picker.list"); 111 + pub const FILE_PICKER_FILENAME_PLACEHOLDER: StringKey = 112 + StringKey::new("file_picker.filename_placeholder"); 113 + pub const FILE_PICKER_DIR_EMPTY: StringKey = StringKey::new("file_picker.dir.empty"); 114 + pub const FILE_OVERWRITE_TITLE: StringKey = StringKey::new("file.overwrite.title"); 115 + pub const FILE_OVERWRITE_MESSAGE: StringKey = StringKey::new("file.overwrite.message"); 116 + pub const FILE_OVERWRITE_REPLACE: StringKey = StringKey::new("file.overwrite.replace"); 117 + pub const FILE_OVERWRITE_CANCEL: StringKey = StringKey::new("file.overwrite.cancel"); 118 + pub const FILE_DISCARD_TITLE: StringKey = StringKey::new("file.discard.title"); 119 + pub const FILE_DISCARD_MESSAGE: StringKey = StringKey::new("file.discard.message"); 120 + pub const FILE_DISCARD_CONFIRM: StringKey = StringKey::new("file.discard.confirm"); 121 + pub const FILE_DISCARD_CANCEL: StringKey = StringKey::new("file.discard.cancel"); 122 + pub const DEFAULT_DOCUMENT_NAME: StringKey = StringKey::new("file.default_document_name"); 123 + pub const NOTIFY_SAVE_FAILED: StringKey = StringKey::new("notify.save_failed"); 124 + pub const NOTIFY_LOAD_FAILED: StringKey = StringKey::new("notify.load_failed"); 125 + pub const NOTIFY_SCAN_FAILED: StringKey = StringKey::new("notify.scan_failed"); 126 + pub const NOTIFY_SAVED: StringKey = StringKey::new("notify.saved"); 127 + pub const NOTIFY_DISMISS: StringKey = StringKey::new("notify.dismiss"); 104 128 pub const MENU_EDIT_UNDO: StringKey = StringKey::new("menu.edit.undo"); 105 129 pub const MENU_EDIT_REDO: StringKey = StringKey::new("menu.edit.redo"); 106 130 pub const MENU_VIEW_ZOOM_FIT: StringKey = StringKey::new("menu.view.zoom_fit"); 107 131 pub const MENU_PLACEHOLDER_COMING_SOON: StringKey = StringKey::new("menu.placeholder.coming_soon"); 108 132 pub const MENU_TOOLS_OPTIONS: StringKey = StringKey::new("menu.tools.options"); 109 133 pub const SETTINGS_DIALOG_TITLE: StringKey = StringKey::new("settings.dialog.title"); 110 - pub const SETTINGS_PICK_APERTURE_LABEL: StringKey = 111 - StringKey::new("settings.pick_aperture.label"); 134 + pub const SETTINGS_PICK_APERTURE_LABEL: StringKey = StringKey::new("settings.pick_aperture.label"); 112 135 pub const SETTINGS_PICK_APERTURE_HINT: StringKey = StringKey::new("settings.pick_aperture.hint"); 113 136 pub const SETTINGS_RESET: StringKey = StringKey::new("settings.reset"); 114 137 pub const SETTINGS_CLOSE: StringKey = StringKey::new("settings.close"); ··· 260 283 (MENU_WINDOW, "Window"), 261 284 (MENU_HELP, "Help"), 262 285 (MENU_FILE_NEW, "New"), 263 - (MENU_FILE_OPEN, "Open"), 286 + (MENU_FILE_OPEN, "Open..."), 264 287 (MENU_FILE_SAVE, "Save"), 288 + (MENU_FILE_SAVE_AS, "Save As..."), 265 289 (MENU_FILE_QUIT, "Quit"), 290 + (FILE_PICKER_TITLE_OPEN, "Open Document"), 291 + (FILE_PICKER_TITLE_SAVE_AS, "Save Document As"), 292 + (FILE_PICKER_OPEN, "Open"), 293 + (FILE_PICKER_SAVE, "Save"), 294 + (FILE_PICKER_CANCEL, "Cancel"), 295 + (FILE_PICKER_LIST, "Document folders"), 296 + (FILE_PICKER_FILENAME_PLACEHOLDER, "Document name"), 297 + (FILE_PICKER_DIR_EMPTY, "No documents in this folder"), 298 + (FILE_OVERWRITE_TITLE, "Replace document?"), 299 + ( 300 + FILE_OVERWRITE_MESSAGE, 301 + "A document already exists at this location.", 302 + ), 303 + (FILE_OVERWRITE_REPLACE, "Replace"), 304 + (FILE_OVERWRITE_CANCEL, "Cancel"), 305 + (FILE_DISCARD_TITLE, "Discard unsaved changes?"), 306 + ( 307 + FILE_DISCARD_MESSAGE, 308 + "Continuing will permanently lose your unsaved edits.", 309 + ), 310 + (FILE_DISCARD_CONFIRM, "Discard"), 311 + (FILE_DISCARD_CANCEL, "Keep editing"), 312 + (DEFAULT_DOCUMENT_NAME, "Untitled"), 313 + (NOTIFY_SAVE_FAILED, "Save failed"), 314 + (NOTIFY_LOAD_FAILED, "Open failed"), 315 + (NOTIFY_SCAN_FAILED, "Could not read documents folder"), 316 + (NOTIFY_SAVED, "Saved"), 317 + (NOTIFY_DISMISS, "Dismiss"), 266 318 (MENU_EDIT_UNDO, "Undo"), 267 319 (MENU_EDIT_REDO, "Redo"), 268 320 (MENU_VIEW_ZOOM_FIT, "Zoom to Fit"), ··· 417 469 (MENU_WINDOW, "[!! Wîndow !!]"), 418 470 (MENU_HELP, "[!! Hêlp !!]"), 419 471 (MENU_FILE_NEW, "[!! Néw !!]"), 420 - (MENU_FILE_OPEN, "[!! Ôpen !!]"), 472 + (MENU_FILE_OPEN, "[!! Ôpen... !!]"), 421 473 (MENU_FILE_SAVE, "[!! Sâve !!]"), 474 + (MENU_FILE_SAVE_AS, "[!! Sâve Âs... !!]"), 422 475 (MENU_FILE_QUIT, "[!! Quît !!]"), 476 + (FILE_PICKER_TITLE_OPEN, "[!! Ôpen Dôcument !!]"), 477 + (FILE_PICKER_TITLE_SAVE_AS, "[!! Sâve Dôcument Âs !!]"), 478 + (FILE_PICKER_OPEN, "[!! Ôpen !!]"), 479 + (FILE_PICKER_SAVE, "[!! Sâve !!]"), 480 + (FILE_PICKER_CANCEL, "[!! Cancêl !!]"), 481 + (FILE_PICKER_LIST, "[!! Dôcument fôlders !!]"), 482 + (FILE_PICKER_FILENAME_PLACEHOLDER, "[!! Dôcument nâme !!]"), 483 + (FILE_PICKER_DIR_EMPTY, "[!! Nô dôcuments în thîs fôlder !!]"), 484 + (FILE_OVERWRITE_TITLE, "[!! Replâce dôcument? !!]"), 485 + ( 486 + FILE_OVERWRITE_MESSAGE, 487 + "[!! Â dôcument alrêady êxists ât thîs locâtion. !!]", 488 + ), 489 + (FILE_OVERWRITE_REPLACE, "[!! Replâce !!]"), 490 + (FILE_OVERWRITE_CANCEL, "[!! Cancêl !!]"), 491 + (FILE_DISCARD_TITLE, "[!! Discârd unsâved chânges? !!]"), 492 + ( 493 + FILE_DISCARD_MESSAGE, 494 + "[!! Continûing will pêrmanently lôse yôur unsâved edîts. !!]", 495 + ), 496 + (FILE_DISCARD_CONFIRM, "[!! Discârd !!]"), 497 + (FILE_DISCARD_CANCEL, "[!! Kêep edîting !!]"), 498 + (DEFAULT_DOCUMENT_NAME, "[!! Untîtled !!]"), 499 + (NOTIFY_SAVE_FAILED, "[!! Sâve fâiled !!]"), 500 + (NOTIFY_LOAD_FAILED, "[!! Ôpen fâiled !!]"), 501 + ( 502 + NOTIFY_SCAN_FAILED, 503 + "[!! Côuld not rêad dôcuments fôlder !!]", 504 + ), 505 + (NOTIFY_SAVED, "[!! Sâved !!]"), 506 + (NOTIFY_DISMISS, "[!! Dîsmiss !!]"), 423 507 (MENU_EDIT_UNDO, "[!! Undô !!]"), 424 508 (MENU_EDIT_REDO, "[!! Redô !!]"), 425 509 (MENU_VIEW_ZOOM_FIT, "[!! Zôom to Fît !!]"),
+2 -2
crates/bone-document/src/io/folder.rs
··· 154 154 .try_for_each(|(id, sketch)| -> Result<(), FolderError> { 155 155 let file = SketchFile::new(sketch.clone()); 156 156 let ron = to_ron(&folder.sketch_path(id), &file)?; 157 - atomic_write(&folder.sketch_path(id), &ron) 157 + write_if_different(&folder.sketch_path(id), &ron) 158 158 })?; 159 159 160 160 let document_ron = to_ron(&folder.document_file(), document.header())?; 161 - atomic_write(&folder.document_file(), &document_ron)?; 161 + write_if_different(&folder.document_file(), &document_ron)?; 162 162 163 163 let registry_ids: BTreeSet<SketchId> = document.registry().order().iter().copied().collect(); 164 164 remove_stale_sketches(&folder.sketches_dir(), &registry_ids)?;