mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8537f1c658 | |||
| ded10b52da | |||
| 4bc5bdc791 | |||
| 1baaebd3ef | |||
| 97e467f2d1 | |||
| 665ff84495 | |||
| 07b3b92703 | |||
| ddf56e1246 | |||
| 0ddbe87935 | |||
| 19dc8b6881 | |||
| 3e2e229dd1 | |||
| c8d9a4bfdd | |||
| 6c764dce06 | |||
| 655e771455 | |||
| 15f74b7981 | |||
| 9b607f6f1f | |||
| ab67126eec | |||
| 3b8cbc4e22 | |||
| f59f6698f1 |
Generated
+13
-194
@@ -130,21 +130,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher 0.4.4",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
|
||||
dependencies = [
|
||||
"cipher 0.5.2",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -159,21 +148,6 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -530,15 +504,6 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -579,16 +544,6 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -800,16 +755,7 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher 0.4.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
|
||||
dependencies = [
|
||||
"cipher 0.5.2",
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -898,17 +844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"inout 0.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"inout 0.2.5",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1033,12 +969,6 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1148,9 +1078,9 @@ version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
|
||||
dependencies = [
|
||||
"aes 0.8.4",
|
||||
"block-padding 0.3.3",
|
||||
"cbc 0.1.2",
|
||||
"aes",
|
||||
"block-padding",
|
||||
"cbc",
|
||||
"dbus",
|
||||
"fastrand",
|
||||
"hkdf",
|
||||
@@ -1260,15 +1190,6 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecb"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac"
|
||||
dependencies = [
|
||||
"cipher 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.36.2"
|
||||
@@ -1785,7 +1706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl 0.1.12",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2243,20 +2164,10 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding 0.3.3",
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
|
||||
dependencies = [
|
||||
"block-padding 0.4.2",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
@@ -2521,33 +2432,6 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lopdf"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfffda0fe1ab0157e1a13c14bebd3f28671f2fccb7922f0722ec53926e6922d3"
|
||||
dependencies = [
|
||||
"aes 0.9.3",
|
||||
"bitflags 2.13.2",
|
||||
"brotli-decompressor",
|
||||
"cbc 0.2.1",
|
||||
"ecb",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"log",
|
||||
"md-5",
|
||||
"nom",
|
||||
"rand 0.10.3",
|
||||
"rangemap",
|
||||
"sha2 0.11.1",
|
||||
"stringprep",
|
||||
"thiserror 2.0.21",
|
||||
"weezl 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matext4cgmath"
|
||||
version = "0.1.0"
|
||||
@@ -2557,16 +2441,6 @@ dependencies = [
|
||||
"num-complex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -2750,15 +2624,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
@@ -3565,7 +3430,6 @@ version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
@@ -3610,12 +3474,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
|
||||
|
||||
[[package]]
|
||||
name = "rangemap"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -4000,8 +3858,8 @@ version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
|
||||
dependencies = [
|
||||
"aes 0.8.4",
|
||||
"cbc 0.1.2",
|
||||
"aes",
|
||||
"cbc",
|
||||
"futures-util",
|
||||
"generic-array",
|
||||
"hkdf",
|
||||
@@ -4542,7 +4400,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcraft-geom",
|
||||
"weezl 0.1.12",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4574,7 +4432,6 @@ dependencies = [
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"log",
|
||||
"lopdf",
|
||||
"minisign-verify",
|
||||
"nix 0.31.3",
|
||||
"pollster 0.4.0",
|
||||
@@ -4653,17 +4510,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -4720,7 +4566,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -4785,7 +4631,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl 0.1.12",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
@@ -4824,12 +4670,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
@@ -5135,21 +4975,6 @@ version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
@@ -5551,12 +5376,6 @@ version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2"
|
||||
|
||||
[[package]]
|
||||
name = "wgpu"
|
||||
version = "30.0.1"
|
||||
|
||||
@@ -1928,6 +1928,9 @@
|
||||
"prefs.max_file_size_step": "ملفات STEP والاستيراد",
|
||||
"prefs.max_file_size_web": "يفتح تطبيق الويب ملفات STEP والاستيراد حتى 512 MB والتصاميم حتى 256 MB؛ لا يتسع تبويب المتصفح لملفات أكبر. يمكن رفع هذه الحدود في تطبيق سطح المكتب.",
|
||||
"prefs.megabytes": "MB",
|
||||
"prefs.menu_grid": "أيقونات كبيرة",
|
||||
"prefs.menu_list": "قائمة",
|
||||
"prefs.menus": "القوائم",
|
||||
"prefs.minutes": "دقائق",
|
||||
"prefs.minutes_suffix": " دقيقة",
|
||||
"prefs.mouse": "الفأرة ولوحة اللمس",
|
||||
@@ -2069,6 +2072,8 @@
|
||||
"prefs.view_confirm_corner": "موافق وإلغاء في زاوية العرض",
|
||||
"prefs.view_confirm_corner_note": "أثناء فتح مربع حوار أو رسم، تؤدي علامة الصح وعلامة الإكس أعلى يمين العرض ما يؤديه زرّا موافق وإلغاء في مربع الحوار، أو تنهي الرسم وتلغيه. وينتقل متصفح العرض إلى أسفلهما.",
|
||||
"prefs.viewport": "منطقة العرض",
|
||||
"prefs.viewport_analysis_status": "إظهار حالة التحليل في منفذ العرض",
|
||||
"prefs.viewport_analysis_status_help": "عرض التحليلات النشطة أعلى منفذ العرض وفتح مفتاح كل تحليل من عنصره. ينهي Esc أحدث تحليل. أوقف الخيار لاستعادة المفاتيح في الزاوية وسلوك Esc السابق.",
|
||||
"prefs.viewport_background": "الخلفية",
|
||||
"prefs.viewport_background_colour": "لون",
|
||||
"prefs.viewport_background_theme": "تدرج السمة",
|
||||
@@ -4479,6 +4484,7 @@
|
||||
"ui.revolve": "دوران",
|
||||
"ui.ribbon_exit": "خروج",
|
||||
"ui.ribbon_labels": "إظهار النص تحت أيقونات الشريط",
|
||||
"ui.ribbon_menus_skip_pinned": "استبعاد الأوامر المثبتة من القوائم المنسدلة",
|
||||
"ui.ribbon_pin_footer": "الأوامر المثبتة تظهر في الشريط. مرّر المؤشر على صف لتثبيته أو إلغاء تثبيته.",
|
||||
"ui.ribbon_workspace": "مساحة العمل",
|
||||
"ui.right": "يمين",
|
||||
@@ -4642,6 +4648,8 @@
|
||||
"ui.view_centre": "مركز العرض",
|
||||
"ui.viewport_status_clear": "إيقاف {name}",
|
||||
"ui.viewport_status_clear_hint": "إيقاف {name} · Esc",
|
||||
"ui.viewport_status_edges": "{count} حواف",
|
||||
"ui.viewport_status_from": "من {direction}",
|
||||
"ui.viewport_status_key": "إظهار مفتاح الألوان لـ {name}",
|
||||
"ui.viewport_status_minimum_radius": "أقل نصف قطر",
|
||||
"ui.viewport_status_pairs": "{count} أزواج",
|
||||
|
||||
@@ -1870,6 +1870,9 @@
|
||||
"prefs.max_file_size_step": "STEP- und Importdateien",
|
||||
"prefs.max_file_size_web": "Die Web-App öffnet STEP- und Importdateien bis 512 MB und Designs bis 256 MB: Ein Browser-Tab kann keine größeren Dateien aufnehmen. In der Desktop-App lassen sich diese Grenzen erhöhen.",
|
||||
"prefs.megabytes": "MB",
|
||||
"prefs.menu_grid": "Große Symbole",
|
||||
"prefs.menu_list": "Liste",
|
||||
"prefs.menus": "Menüs",
|
||||
"prefs.minutes_suffix": " min",
|
||||
"prefs.origin_plane_names": "Namen der Ursprungsebenen",
|
||||
"prefs.origin_plane_names_note": "Namen in Browser, Dialogen und Listen. {top} ist XY, {front} ist XZ und {right} ist YZ, mit Z nach oben. Befehle, Skripte und gespeicherte Konstruktionen behalten XY, XZ und YZ.",
|
||||
@@ -1939,6 +1942,8 @@
|
||||
"prefs.trackpad_zoom_modifier.ctrl": "Strg",
|
||||
"prefs.trackpad_zoom_modifier.help": "Nur diese Taste gedrückt halten, um durch vertikales Scrollen am Zeiger zu zoomen. Aus behält das bisherige Scrollverhalten bei.",
|
||||
"prefs.trackpad_zoom_modifier.off": "Aus",
|
||||
"prefs.viewport_analysis_status": "Analysestatus im Ansichtsfenster anzeigen",
|
||||
"prefs.viewport_analysis_status_help": "Zeigt aktive Analysen über dem Ansichtsfenster und ihre Legenden am jeweiligen Eintrag. Esc beendet die neueste Analyse. Ausschalten stellt Ecklegenden und das bisherige Esc-Verhalten wieder her.",
|
||||
"prefs.viewport_background": "Hintergrund",
|
||||
"prefs.viewport_background_colour": "Farbe",
|
||||
"prefs.viewport_background_theme": "Verlauf des Designs",
|
||||
@@ -3790,6 +3795,7 @@
|
||||
"ui.revolve": "Drehen",
|
||||
"ui.ribbon_exit": "Beenden",
|
||||
"ui.ribbon_labels": "Text unter den Symbolen im Menüband anzeigen",
|
||||
"ui.ribbon_menus_skip_pinned": "Angeheftete Befehle nicht in den Aufklappmenüs wiederholen",
|
||||
"ui.ribbon_pin_footer": "Angeheftete Befehle erscheinen im Menüband. Fahren Sie über eine Zeile, um sie anzuheften oder zu lösen.",
|
||||
"ui.ribbon_workspace": "Arbeitsbereich",
|
||||
"ui.right": "Rechts",
|
||||
@@ -3935,6 +3941,8 @@
|
||||
"ui.view_centre": "Ansichtszentrum",
|
||||
"ui.viewport_status_clear": "{name} ausschalten",
|
||||
"ui.viewport_status_clear_hint": "{name} ausschalten · Esc",
|
||||
"ui.viewport_status_edges": "{count} Kanten",
|
||||
"ui.viewport_status_from": "aus {direction}",
|
||||
"ui.viewport_status_key": "Legende für {name} anzeigen",
|
||||
"ui.viewport_status_minimum_radius": "Mindestradius",
|
||||
"ui.viewport_status_pairs": "{count} Paare",
|
||||
|
||||
@@ -1928,6 +1928,9 @@
|
||||
"prefs.max_file_size_step": "STEP and import files",
|
||||
"prefs.max_file_size_web": "The web app opens STEP and import files up to 512 MB and designs up to 256 MB: a browser tab can't hold larger files. The desktop app can raise these limits.",
|
||||
"prefs.megabytes": "MB",
|
||||
"prefs.menu_grid": "Large icons",
|
||||
"prefs.menu_list": "List",
|
||||
"prefs.menus": "Menus",
|
||||
"prefs.minutes": "minutes",
|
||||
"prefs.minutes_suffix": " min",
|
||||
"prefs.mouse": "Mouse and trackpad",
|
||||
@@ -2069,6 +2072,8 @@
|
||||
"prefs.view_confirm_corner": "OK and Cancel in the view's corner",
|
||||
"prefs.view_confirm_corner_note": "While a dialog or a sketch is open, a check and a cross at the top right of the view do what the dialog's OK and Cancel do, or finish and cancel the sketch. The view navigator moves down below them.",
|
||||
"prefs.viewport": "Viewport",
|
||||
"prefs.viewport_analysis_status": "Show viewport analysis status",
|
||||
"prefs.viewport_analysis_status_help": "Show active analyses above the viewport, open their legends from each item, and use Esc to turn off the newest analysis. Turn off to restore corner legends and the previous Esc behaviour.",
|
||||
"prefs.viewport_background": "Background",
|
||||
"prefs.viewport_background_colour": "Color",
|
||||
"prefs.viewport_background_theme": "Theme's gradient",
|
||||
@@ -4479,6 +4484,7 @@
|
||||
"ui.revolve": "Revolve",
|
||||
"ui.ribbon_exit": "Exit",
|
||||
"ui.ribbon_labels": "Show text under ribbon icons",
|
||||
"ui.ribbon_menus_skip_pinned": "Leave pinned commands out of the drop-down menus",
|
||||
"ui.ribbon_pin_footer": "Pinned commands show on the ribbon. Hover a row to pin or unpin it.",
|
||||
"ui.ribbon_workspace": "Workspace",
|
||||
"ui.right": "Right",
|
||||
@@ -4642,6 +4648,8 @@
|
||||
"ui.view_centre": "View center",
|
||||
"ui.viewport_status_clear": "Turn off {name}",
|
||||
"ui.viewport_status_clear_hint": "Turn off {name} · Esc",
|
||||
"ui.viewport_status_edges": "{count} edges",
|
||||
"ui.viewport_status_from": "from {direction}",
|
||||
"ui.viewport_status_key": "Show key for {name}",
|
||||
"ui.viewport_status_minimum_radius": "Minimum radius",
|
||||
"ui.viewport_status_pairs": "{count} pairs",
|
||||
|
||||
@@ -1928,6 +1928,9 @@
|
||||
"prefs.max_file_size_step": "קובצי STEP וייבוא",
|
||||
"prefs.max_file_size_web": "יישום הדפדפן פותח קובצי STEP וייבוא עד 512 MB וקובצי תכן עד 256 MB: לשונית דפדפן אינה יכולה להכיל קבצים גדולים יותר. ביישום למחשב ניתן להעלות מגבלות אלה.",
|
||||
"prefs.megabytes": "MB",
|
||||
"prefs.menu_grid": "סמלים גדולים",
|
||||
"prefs.menu_list": "רשימה",
|
||||
"prefs.menus": "תפריטים",
|
||||
"prefs.minutes": "דקות",
|
||||
"prefs.minutes_suffix": " דק'",
|
||||
"prefs.mouse": "עכבר ומשטח מגע",
|
||||
@@ -2069,6 +2072,8 @@
|
||||
"prefs.view_confirm_corner": "אישור וביטול בפינת התצוגה",
|
||||
"prefs.view_confirm_corner_note": "כשתיבת דו־שיח או סקיצה פתוחות, סימן וי וסימן איקס בפינה הימנית העליונה של התצוגה עושים את מה שעושים אישור וביטול של תיבת הדו־שיח, או מסיימים ומבטלים את הסקיצה. ניווט התצוגה זז אל מתחתיהם.",
|
||||
"prefs.viewport": "אזור התצוגה",
|
||||
"prefs.viewport_analysis_status": "הצגת מצב ניתוח בחלון התצוגה",
|
||||
"prefs.viewport_analysis_status_help": "הצגת ניתוחים פעילים מעל חלון התצוגה ופתיחת המקרא מכל פריט. Esc מכבה את הניתוח האחרון. כיבוי האפשרות משחזר את המקרא בפינה ואת פעולת Esc הקודמת.",
|
||||
"prefs.viewport_background": "רקע",
|
||||
"prefs.viewport_background_colour": "צבע",
|
||||
"prefs.viewport_background_theme": "מעבר הצבעים של ערכת הנושא",
|
||||
@@ -4479,6 +4484,7 @@
|
||||
"ui.revolve": "סיבוב",
|
||||
"ui.ribbon_exit": "יציאה",
|
||||
"ui.ribbon_labels": "הצגת טקסט מתחת לסמלי רצועת הכלים",
|
||||
"ui.ribbon_menus_skip_pinned": "השמטת פקודות מוצמדות מהתפריטים הנפתחים",
|
||||
"ui.ribbon_pin_footer": "פקודות מוצמדות מוצגות ברצועת הכלים. יש לרחף מעל שורה כדי להצמיד אותה או לבטל את הצמדתה.",
|
||||
"ui.ribbon_workspace": "סביבת עבודה",
|
||||
"ui.right": "ימין",
|
||||
@@ -4642,6 +4648,8 @@
|
||||
"ui.view_centre": "מרכז המבט",
|
||||
"ui.viewport_status_clear": "כיבוי {name}",
|
||||
"ui.viewport_status_clear_hint": "כיבוי {name} · Esc",
|
||||
"ui.viewport_status_edges": "{count} קצוות",
|
||||
"ui.viewport_status_from": "מכיוון {direction}",
|
||||
"ui.viewport_status_key": "הצגת מקרא עבור {name}",
|
||||
"ui.viewport_status_minimum_radius": "רדיוס מינימלי",
|
||||
"ui.viewport_status_pairs": "{count} זוגות",
|
||||
|
||||
@@ -775,6 +775,8 @@
|
||||
"at the face": "na licu",
|
||||
"bom.group_children": "Podređeni dijelovi u retku svojeg dijela",
|
||||
"browser.body_name": "Tijelo{number}",
|
||||
"browser.calibrate_click_a": "Kliknite sliku kako biste zadali točku A ili upišite njezine koordinate.",
|
||||
"browser.calibrate_click_b": "Kliknite sliku kako biste zadali točku B ili upišite njezine koordinate.",
|
||||
"browser.calibrate_title": "Kalibriraj: {name}",
|
||||
"browser.capture_many": "Pomaknute komponente bez zabilježenog položaja: {count}.",
|
||||
"browser.capture_note": "Zabilježi zadržava novi položaj; Vrati ga vraća nazad.",
|
||||
@@ -1399,6 +1401,16 @@
|
||||
"help.occurrence.move": "Pomiče dio sklopa samo u stupnjevima slobode koje dopuštaju njegovi spojevi. Odaberite lice dijela, a zatim povucite dostupnu strelicu ili luk.",
|
||||
"help.parameters.change": "Otvara tablicu imenovanih vrijednosti i izraza koji upravljaju kotama dizajna. Upotrijebite je kako biste promijenili mjeru na jednom mjestu, a sve značajke koje je koriste je slijede.",
|
||||
"help.pointcloud.insert": "Umetnite PLY, XYZ ili PTS skeniranje kao oblak točaka uz model. Koristite ga kao referencu pri izradi modela iz izmjerenih podataka.",
|
||||
"help.sketch.construction_line": "Izradite beskonačni pomoćni pravac kroz dvije točke. Upotrijebite ga kako biste rasporedili skicu bez razdvajanja profila.",
|
||||
"help.sketch.construction_line.angle": "Izradite beskonačni pomoćni pravac pod kutom od osi X skice. Odaberite točku i upišite kut ili odaberite smjer.",
|
||||
"help.sketch.construction_line.bisector": "Odaberite dvije ravne linije skice koje se sijeku, pa jednu od dviju simetrala kuta. Rezultat prati izvorne linije.",
|
||||
"help.sketch.construction_line.horizontal": "Izradite beskonačni vodoravni pomoćni pravac kroz točku. Njegovo ograničenje vodoravnosti ostaje na snazi pri kasnijim promjenama.",
|
||||
"help.sketch.construction_line.parallel": "Odaberite ravnu liniju skice, pa postavite paralelni beskonačni pomoćni pravac. Desnim klikom odaberite drugu referencu.",
|
||||
"help.sketch.construction_line.perpendicular": "Odaberite ravnu liniju skice, pa postavite beskonačni pomoćni pravac okomit na nju. Desnim klikom odaberite drugu referencu.",
|
||||
"help.sketch.construction_line.tangent_at": "Izradite tangentu u točki na kružnici ili kružnom luku, ili u krajnjoj točki otvorenog splinea ili konike. Odaberite krivulju, pa točku dodira.",
|
||||
"help.sketch.construction_line.tangent_external": "Odaberite kružnicu ili kružni luk, pa vanjsku točku i rješenje tangente. Pregledi prikazuju samo dodirne točke na nacrtanom luku.",
|
||||
"help.sketch.construction_line.tangent_two": "Odaberite dvije kružnice ili kružna luka, pa u pregledu odaberite zajedničku tangentu. Dodirne točke moraju ležati na nacrtanim krivuljama.",
|
||||
"help.sketch.construction_line.vertical": "Izradite beskonačni okomiti pomoćni pravac kroz točku. Njegovo ograničenje okomitosti ostaje na snazi pri kasnijim promjenama.",
|
||||
"help.sketch.equation_curve": "Crta krivulju prema formulama: x(t) i y(t) ili y = f(x), u zadanom rasponu. Formule mogu koristiti parametre i krivulja ih prati: upotrijebite je za sinusoidu, spiralu ili evolventu zuba zupčanika.",
|
||||
"help.solid.align": "Pomiče odabrana tijela tako da se točka, lice ili ravnina na njima poklopi s točkom, licem ili ravninom negdje drugdje. Upotrijebite ga kako biste postavili dio uz drugi bez mjerenja odmaka.",
|
||||
"help.solid.chamfer": "Odsijeca odabrane bridove ravnim skošenjem zadane udaljenosti. Koristite ga kako biste ublažili oštre bridove ili olakšali uvođenje zatika ili vijka.",
|
||||
@@ -1935,6 +1947,8 @@
|
||||
"prefs.origin_plane_names": "Nazivi ravnina ishodišta",
|
||||
"prefs.origin_plane_names_note": "Nazivi prikazani u stablu modela, dijalozima i popisima. {top} je XY, {front} je XZ, a {right} je YZ, sa Z prema gore. Naredbe, skripte i spremljeni dizajni zadržavaju XY, XZ i YZ.",
|
||||
"prefs.overall_speed": "Ukupna brzina",
|
||||
"prefs.persistent_ribbon_pressed": "Istakni aktivne naredbe na vrpci",
|
||||
"prefs.persistent_ribbon_pressed_help": "Ističe naredbe na vrpci dok su njihovi efekti uključeni. Isključite kako biste zadržali prethodni neutralni izgled i pomoć za naredbe.",
|
||||
"prefs.perspective_fov": "Vidno polje",
|
||||
"prefs.placement": "Postavljanje",
|
||||
"prefs.prefer_free_sketch_points": "Daj prednost pomičnim točkama skice kad se točke preklapaju",
|
||||
@@ -1984,11 +1998,14 @@
|
||||
"prefs.sketch_align_points_note": "Tijekom crtanja točka se istodobno poravnava vodoravno s jednom točkom skice i okomito s drugom, uz točkastu vodilicu, a klik zadržava oba poravnanja. Isključeno: samo s prethodnom točkom, po jedna os.",
|
||||
"prefs.sketch_conflict_colour": "Geometrija u sukobu u boji sukoba",
|
||||
"prefs.sketch_conflict_colour_note": "Dok su ograničenja skice u sukobu, krivulje i točke koje drži neuspjelo ograničenje crtaju se i u boji sukoba, a ne samo njegov simbol ili kota. Isključeno: zadržavaju uobičajene boje.",
|
||||
"prefs.sketch_construction_lines": "Prošireni alati za pomoćne pravce",
|
||||
"prefs.sketch_exit_view": "Kad se skica završi",
|
||||
"prefs.sketch_exit_view.keep": "Zadrži trenutni prikaz",
|
||||
"prefs.sketch_exit_view.previous": "Vrati se na prethodni prikaz",
|
||||
"prefs.sketch_exit_view.unless_rotated": "Vrati se osim ako je rotirano",
|
||||
"prefs.sketch_exit_view_note": "Kamo prikaz ide nakon Završi skicu, Odustani ili dvostrukog Esc.",
|
||||
"prefs.sketch_open_look_at": "Okreni kameru prema skici pri otvaranju",
|
||||
"prefs.sketch_open_look_at_note": "Isključeno: kad izradite ili uredite skicu, prikaz ostaje onakav kakav jest. Pogledaj okreće prikaz prema skici kad to želite.",
|
||||
"prefs.sketch_recent_snap": "Automatski privlači na nedavna središta i središnje točke",
|
||||
"prefs.sketch_recent_snap_note": "Tijekom crtanja pokazivač se privlači ondje gdje se sijeku vodilice dvaju posljednjih središta kružnica ili središnjih točaka ravnih bridova. Isključeno: te vodilice služe samo za prikaz i pokazivač se ne pomiče.",
|
||||
"prefs.sketch_selection": "Skica i odabir",
|
||||
@@ -2064,6 +2081,10 @@
|
||||
"properties.title": "Svojstva: {name}",
|
||||
"recovery.days_ago": "prije {count} dana",
|
||||
"recovery.detail": "{file} · značajki: {features} · automatski spremljeno {when}",
|
||||
"recovery.discard_all": "Odbaci sve",
|
||||
"recovery.discard_all_detail": "Ovo se ne može poništiti.",
|
||||
"recovery.discard_all_title": "Odbaciti automatska spremanja ({count})?",
|
||||
"recovery.discard_all_title_one": "Odbaciti 1 automatsko spremanje?",
|
||||
"recovery.hours_ago": "prije {count} h",
|
||||
"recovery.just_now": "upravo",
|
||||
"recovery.min_ago": "prije {count} min",
|
||||
@@ -3983,6 +4004,14 @@
|
||||
"ui.constant": "Konstantno",
|
||||
"ui.constraints_conflict": "Ograničenja su u sukobu",
|
||||
"ui.construction": "Pomoćna",
|
||||
"ui.construction.angle_hint": "Pomoćni pravac: kliknite točku, pa upišite kut ili odaberite smjer",
|
||||
"ui.construction.bisector_hint": "Simetrala: odaberite dvije ravne linije koje se sijeku, pa željeno rješenje",
|
||||
"ui.construction.common_hint": "Zajednička tangenta: odaberite dvije kružnice ili dva luka, pa željeno rješenje",
|
||||
"ui.construction.contact_hint": "Tangenta: odaberite kružnicu/luk ili otvoreni spline/koniku, pa točku dodira (krivulje slobodnog oblika: krajnje točke)",
|
||||
"ui.construction.external_hint": "Tangenta: odaberite kružnicu ili luk, vanjsku točku, pa željeno rješenje",
|
||||
"ui.construction.free_hint": "Pomoćni pravac: kliknite dvije točke; desnim klikom počnite ispočetka",
|
||||
"ui.construction.point_hint": "Pomoćni pravac: kliknite točku kroz koju prolazi",
|
||||
"ui.construction.reference_hint": "Pomoćni pravac: odaberite ravnu liniju, pa točku; desnim klikom promijenite referencu",
|
||||
"ui.construction.ribbon_label": "Pomoćne",
|
||||
"ui.context_menu": "Kontekstni izbornik",
|
||||
"ui.continuity_g0": "G0 pregib",
|
||||
|
||||
@@ -1928,6 +1928,9 @@
|
||||
"prefs.max_file_size_step": "STEP・インポートファイル",
|
||||
"prefs.max_file_size_web": "ウェブ版で開けるのは STEP・インポートファイルが 512 MB、デザインが 256 MB までです。ブラウザーのタブはそれ以上のファイルを保持できません。デスクトップ版では上限を上げられます。",
|
||||
"prefs.megabytes": "MB",
|
||||
"prefs.menu_grid": "大きなアイコン",
|
||||
"prefs.menu_list": "リスト",
|
||||
"prefs.menus": "メニュー",
|
||||
"prefs.minutes": "分",
|
||||
"prefs.minutes_suffix": " min",
|
||||
"prefs.mouse": "マウスとトラックパッド",
|
||||
@@ -2069,6 +2072,8 @@
|
||||
"prefs.view_confirm_corner": "ビューの隅に OK とキャンセル",
|
||||
"prefs.view_confirm_corner_note": "ダイアログまたはスケッチを開いている間、ビュー右上のチェックとバツが、ダイアログの OK とキャンセルと同じ操作を行うか、スケッチを終了またはキャンセルします。ビューナビゲーターはその下に移動します。",
|
||||
"prefs.viewport": "ビューポート",
|
||||
"prefs.viewport_analysis_status": "ビューポートの解析状態を表示",
|
||||
"prefs.viewport_analysis_status_help": "有効な解析をビューポートの上部に表示し、各項目から凡例を開きます。Esc で最新の解析を解除します。無効にすると隅の凡例と従来の Esc 動作に戻ります。",
|
||||
"prefs.viewport_background": "背景",
|
||||
"prefs.viewport_background_colour": "色",
|
||||
"prefs.viewport_background_theme": "テーマのグラデーション",
|
||||
@@ -4479,6 +4484,7 @@
|
||||
"ui.revolve": "回転",
|
||||
"ui.ribbon_exit": "終了",
|
||||
"ui.ribbon_labels": "リボンのアイコンの下にテキストを表示",
|
||||
"ui.ribbon_menus_skip_pinned": "ピン留めしたコマンドをドロップダウンメニューに表示しない",
|
||||
"ui.ribbon_pin_footer": "ピン留めしたコマンドはリボンに表示されます。行にポインターを合わせて、ピン留めや解除ができます。",
|
||||
"ui.ribbon_workspace": "ワークスペース",
|
||||
"ui.right": "右側面",
|
||||
@@ -4642,6 +4648,8 @@
|
||||
"ui.view_centre": "ビュー中心",
|
||||
"ui.viewport_status_clear": "{name}をオフにする",
|
||||
"ui.viewport_status_clear_hint": "{name}をオフにする · Esc",
|
||||
"ui.viewport_status_edges": "{count} エッジ",
|
||||
"ui.viewport_status_from": "方向 {direction}",
|
||||
"ui.viewport_status_key": "{name}の凡例を表示",
|
||||
"ui.viewport_status_minimum_radius": "最小半径",
|
||||
"ui.viewport_status_pairs": "{count} 組",
|
||||
|
||||
@@ -1699,6 +1699,8 @@
|
||||
"prefs.view_confirm_corner": "뷰 모서리에 확인 및 취소",
|
||||
"prefs.view_confirm_corner_note": "대화 상자나 스케치가 열려 있는 동안 뷰 오른쪽 위의 체크와 X 표시가 대화 상자의 확인과 취소와 같은 동작을 하거나 스케치를 마치고 취소합니다. 뷰 탐색기는 그 아래로 내려갑니다.",
|
||||
"prefs.viewport": "뷰포트",
|
||||
"prefs.viewport_analysis_status": "뷰포트 해석 상태 표시",
|
||||
"prefs.viewport_analysis_status_help": "활성 해석을 뷰포트 위에 표시하고 각 항목에서 범례를 엽니다. Esc로 최신 해석을 끕니다. 끄면 모서리 범례와 이전 Esc 동작으로 돌아갑니다.",
|
||||
"prefs.wheel": "휠",
|
||||
"prefs.wheel_trackpad": "휠 및 트랙패드",
|
||||
"prefs.workspace": "작업 공간",
|
||||
@@ -3681,6 +3683,8 @@
|
||||
"ui.view_centre": "뷰 중심",
|
||||
"ui.viewport_status_clear": "{name} 끄기",
|
||||
"ui.viewport_status_clear_hint": "{name} 끄기 · Esc",
|
||||
"ui.viewport_status_edges": "모서리 {count}개",
|
||||
"ui.viewport_status_from": "{direction} 방향에서",
|
||||
"ui.viewport_status_key": "{name} 범례 표시",
|
||||
"ui.viewport_status_minimum_radius": "최소 반지름",
|
||||
"ui.viewport_status_pairs": "{count}쌍",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"bs": {
|
||||
"browser.calibrate_click_a": "Kliknite sliku da zadate tačku A ili upišite njene koordinate.",
|
||||
"browser.calibrate_click_b": "Kliknite sliku da zadate tačku B ili upišite njene koordinate.",
|
||||
"help.sketch.construction_line": "Napravite beskonačnu pomoćnu pravu kroz dvije tačke. Koristite je da rasporedite skicu bez razdvajanja profila.",
|
||||
"help.sketch.construction_line.angle": "Napravite beskonačnu pomoćnu pravu pod uglom od ose X skice. Izaberite tačku i upišite ugao ili izaberite smjer.",
|
||||
"help.sketch.construction_line.bisector": "Izaberite dvije prave linije skice koje se sijeku, pa jednu od dvije simetrale ugla. Rezultat prati izvorne linije.",
|
||||
"help.sketch.construction_line.horizontal": "Napravite beskonačnu horizontalnu pomoćnu pravu kroz tačku. Njeno ograničenje horizontalnosti ostaje na snazi pri kasnijim izmjenama.",
|
||||
"help.sketch.construction_line.parallel": "Izaberite pravu liniju skice, pa postavite paralelnu beskonačnu pomoćnu pravu. Desnim klikom izaberite drugu referencu.",
|
||||
"help.sketch.construction_line.perpendicular": "Izaberite pravu liniju skice, pa postavite okomitu beskonačnu pomoćnu pravu. Desnim klikom izaberite drugu referencu.",
|
||||
"help.sketch.construction_line.tangent_at": "Napravite tangentu u tački na krugu ili kružnom luku, ili u krajnjoj tački otvorenog splajna ili konike. Izaberite krivu, pa tačku dodira.",
|
||||
"help.sketch.construction_line.tangent_external": "Izaberite krug ili kružni luk, pa vanjsku tačku i rješenje tangente. Pregledi prikazuju samo dodirne tačke na nacrtanom luku.",
|
||||
"help.sketch.construction_line.tangent_two": "Izaberite dva kruga ili kružna luka, pa u pregledu izaberite zajedničku tangentu. Dodirne tačke moraju ležati na nacrtanim krivama.",
|
||||
"help.sketch.construction_line.vertical": "Napravite beskonačnu vertikalnu pomoćnu pravu kroz tačku. Njeno ograničenje vertikalnosti ostaje na snazi pri kasnijim izmjenama.",
|
||||
"prefs.persistent_ribbon_pressed": "Istakni aktivne komande na traci",
|
||||
"prefs.persistent_ribbon_pressed_help": "Ističe komande na traci dok su njihovi efekti uključeni. Isključite da biste zadržali prethodni neutralni izgled i pomoć za komande.",
|
||||
"prefs.sketch_construction_lines": "Prošireni alati za pomoćne prave",
|
||||
"prefs.sketch_open_look_at": "Okreni kameru prema skici kad se otvori",
|
||||
"prefs.sketch_open_look_at_note": "Isključeno: kad napravite ili uredite skicu, prikaz ostaje onakav kakav jest. Pogledaj okreće prikaz prema skici kad to želite.",
|
||||
"recovery.discard_all": "Odbaci sve",
|
||||
"recovery.discard_all_detail": "Ovo se ne može poništiti.",
|
||||
"recovery.discard_all_title": "Odbaciti automatska čuvanja ({count})?",
|
||||
"recovery.discard_all_title_one": "Odbaciti 1 automatsko čuvanje?",
|
||||
"ui.construction.angle_hint": "Pomoćna prava: kliknite tačku, pa upišite ugao ili izaberite smjer",
|
||||
"ui.construction.bisector_hint": "Simetrala: izaberite dvije prave linije koje se sijeku, pa željeno rješenje",
|
||||
"ui.construction.common_hint": "Zajednička tangenta: izaberite dva kruga ili luka, pa željeno rješenje",
|
||||
"ui.construction.contact_hint": "Tangenta: izaberite krug/luk ili otvoreni splajn/koniku, pa tačku dodira (krive slobodnog oblika: krajnje tačke)",
|
||||
"ui.construction.external_hint": "Tangenta: izaberite krug ili luk, vanjsku tačku, pa željeno rješenje",
|
||||
"ui.construction.free_hint": "Pomoćna prava: kliknite dvije tačke; desnim klikom počnite ispočetka",
|
||||
"ui.construction.point_hint": "Pomoćna prava: kliknite tačku kroz koju prolazi",
|
||||
"ui.construction.reference_hint": "Pomoćna prava: izaberite pravu liniju, pa tačku; desnim klikom promijenite referencu"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"gl": {
|
||||
"browser.calibrate_click_a": "Fai clic na imaxe para o punto A, ou escribe as súas coordenadas.",
|
||||
"browser.calibrate_click_b": "Fai clic na imaxe para o punto B, ou escribe as súas coordenadas.",
|
||||
"help.sketch.construction_line": "Crea unha recta de construción infinita por dous puntos. Úsaa para organizar un bosquexo sen dividir os perfís.",
|
||||
"help.sketch.construction_line.angle": "Crea unha recta de construción infinita cun ángulo respecto do eixe X do bosquexo. Escolle o punto e escribe o ángulo, ou escolle a súa dirección.",
|
||||
"help.sketch.construction_line.bisector": "Escolle dúas rectas do bosquexo que se crucen e despois unha das dúas bisectrices. O resultado segue as súas rectas de orixe.",
|
||||
"help.sketch.construction_line.horizontal": "Crea unha recta de construción horizontal infinita por un punto. A súa restrición horizontal mantense nas edicións posteriores.",
|
||||
"help.sketch.construction_line.parallel": "Escolle unha recta do bosquexo e despois coloca unha recta de construción infinita paralela. Fai clic co botón dereito para escoller outra referencia.",
|
||||
"help.sketch.construction_line.perpendicular": "Escolle unha recta do bosquexo e despois coloca unha recta de construción infinita perpendicular. Fai clic co botón dereito para escoller outra referencia.",
|
||||
"help.sketch.construction_line.tangent_at": "Crea unha tanxente nun punto dun círculo ou dun arco circular, ou no extremo dunha spline ou dunha cónica abertas. Escolle a curva e despois o seu punto de tanxencia.",
|
||||
"help.sketch.construction_line.tangent_external": "Escolle un círculo ou un arco circular e despois un punto exterior e unha solución de tanxente. As vistas previas só mostran os puntos de tanxencia sobre o arco debuxado.",
|
||||
"help.sketch.construction_line.tangent_two": "Escolle dous círculos ou arcos circulares e despois unha tanxente común na vista previa. Os puntos de tanxencia deben estar sobre as curvas debuxadas.",
|
||||
"help.sketch.construction_line.vertical": "Crea unha recta de construción vertical infinita por un punto. A súa restrición vertical mantense nas edicións posteriores.",
|
||||
"prefs.persistent_ribbon_pressed": "Resaltar as ordes activas da cinta",
|
||||
"prefs.persistent_ribbon_pressed_help": "Resalta as ordes da cinta mentres os seus efectos están activados. Desactívao para manter o aspecto neutro e a axuda das ordes anteriores.",
|
||||
"prefs.sketch_construction_lines": "Ferramentas ampliadas de rectas de construción",
|
||||
"prefs.sketch_open_look_at": "Xirar para mirar cara ao bosquexo cando se abre",
|
||||
"prefs.sketch_open_look_at_note": "Desactivado: crear ou editar un bosquexo deixa a vista tal e como está. Mirar cara a orienta a vista cara ao bosquexo cando o queres.",
|
||||
"recovery.discard_all": "Descartar todo",
|
||||
"recovery.discard_all_detail": "Esta acción non se pode desfacer.",
|
||||
"recovery.discard_all_title": "Descartar {count} gardados automáticos?",
|
||||
"recovery.discard_all_title_one": "Descartar 1 gardado automático?",
|
||||
"ui.construction.angle_hint": "Recta de construción: fai clic nun punto e despois escribe un ángulo ou escolle unha dirección",
|
||||
"ui.construction.bisector_hint": "Bisectriz: escolle dúas rectas que se crucen e despois a solución desexada",
|
||||
"ui.construction.common_hint": "Tanxente común: escolle dous círculos/arcos e despois a solución desexada",
|
||||
"ui.construction.contact_hint": "Tanxente: escolle un círculo/arco ou unha spline/cónica aberta e despois o punto de tanxencia (curvas de forma libre: extremos)",
|
||||
"ui.construction.external_hint": "Tanxente: escolle un círculo ou arco, un punto exterior e despois a solución desexada",
|
||||
"ui.construction.free_hint": "Recta de construción: fai clic en dous puntos; clic dereito para reiniciar",
|
||||
"ui.construction.point_hint": "Recta de construción: fai clic no punto polo que debe pasar",
|
||||
"ui.construction.reference_hint": "Recta de construción: escolle unha recta e despois un punto; clic dereito para cambiar a referencia"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"id": {
|
||||
"browser.calibrate_click_a": "Klik gambar untuk Titik A, atau ketik koordinatnya.",
|
||||
"browser.calibrate_click_b": "Klik gambar untuk Titik B, atau ketik koordinatnya.",
|
||||
"help.sketch.construction_line": "Buat garis bantu tak hingga melalui dua titik. Gunakan untuk menata sketsa tanpa memecah profil.",
|
||||
"help.sketch.construction_line.angle": "Buat garis bantu tak hingga dengan sudut terhadap sumbu X sketsa. Pilih titiknya dan ketik sudutnya, atau pilih arahnya.",
|
||||
"help.sketch.construction_line.bisector": "Pilih dua garis sketsa lurus yang berpotongan, lalu pilih salah satu garis bagi sudut. Hasilnya mengikuti garis sumbernya.",
|
||||
"help.sketch.construction_line.horizontal": "Buat garis bantu horizontal tak hingga melalui sebuah titik. Batasan horizontalnya tetap berlaku pada pengeditan berikutnya.",
|
||||
"help.sketch.construction_line.parallel": "Pilih sebuah garis sketsa lurus, lalu tempatkan garis bantu tak hingga yang sejajar. Klik kanan untuk memilih referensi lain.",
|
||||
"help.sketch.construction_line.perpendicular": "Pilih sebuah garis sketsa lurus, lalu tempatkan garis bantu tak hingga yang tegak lurus. Klik kanan untuk memilih referensi lain.",
|
||||
"help.sketch.construction_line.tangent_at": "Buat garis singgung pada sebuah titik di lingkaran atau busur lingkaran, atau pada titik ujung spline atau kurva konik yang terbuka. Pilih kurvanya, lalu titik singgungnya.",
|
||||
"help.sketch.construction_line.tangent_external": "Pilih lingkaran atau busur lingkaran, lalu sebuah titik luar dan satu solusi garis singgung. Pratinjau hanya menampilkan titik singgung pada busur yang digambar.",
|
||||
"help.sketch.construction_line.tangent_two": "Pilih dua lingkaran atau busur lingkaran, lalu pilih garis singgung persekutuan di pratinjau. Titik singgung harus berada pada kurva yang digambar.",
|
||||
"help.sketch.construction_line.vertical": "Buat garis bantu vertikal tak hingga melalui sebuah titik. Batasan vertikalnya tetap berlaku pada pengeditan berikutnya.",
|
||||
"prefs.persistent_ribbon_pressed": "Sorot perintah ribbon yang aktif",
|
||||
"prefs.persistent_ribbon_pressed_help": "Sorot perintah ribbon selama efeknya aktif. Matikan untuk mempertahankan tampilan netral dan bantuan perintah sebelumnya.",
|
||||
"prefs.sketch_construction_lines": "Alat garis bantu lanjutan",
|
||||
"prefs.sketch_open_look_at": "Putar untuk menghadap sketsa saat dibuka",
|
||||
"prefs.sketch_open_look_at_note": "Mati: membuat atau mengedit sketsa membiarkan tampilan apa adanya. Look At menghadapkan tampilan ke sketsa saat Anda menginginkannya.",
|
||||
"recovery.discard_all": "Buang semua",
|
||||
"recovery.discard_all_detail": "Aksi ini tidak dapat diurungkan.",
|
||||
"recovery.discard_all_title": "Buang {count} simpan otomatis?",
|
||||
"recovery.discard_all_title_one": "Buang 1 simpan otomatis?",
|
||||
"ui.construction.angle_hint": "Garis bantu: klik sebuah titik, lalu ketik sudut atau pilih arah",
|
||||
"ui.construction.bisector_hint": "Garis bagi sudut: pilih dua garis lurus yang berpotongan, lalu solusi yang diinginkan",
|
||||
"ui.construction.common_hint": "Garis singgung persekutuan: pilih dua lingkaran/busur, lalu solusi yang diinginkan",
|
||||
"ui.construction.contact_hint": "Garis singgung: pilih lingkaran/busur atau spline/kurva konik terbuka, lalu titik singgung (kurva bentuk bebas: titik ujung)",
|
||||
"ui.construction.external_hint": "Garis singgung: pilih lingkaran atau busur, sebuah titik luar, lalu solusi yang diinginkan",
|
||||
"ui.construction.free_hint": "Garis bantu: klik dua titik; klik kanan untuk mengulang",
|
||||
"ui.construction.point_hint": "Garis bantu: klik titik yang akan dilalui",
|
||||
"ui.construction.reference_hint": "Garis bantu: pilih garis lurus, lalu sebuah titik; klik kanan untuk mengganti referensi"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"ms": {
|
||||
"browser.calibrate_click_a": "Klik imej untuk Titik A, atau taip koordinatnya.",
|
||||
"browser.calibrate_click_b": "Klik imej untuk Titik B, atau taip koordinatnya.",
|
||||
"help.sketch.construction_line": "Cipta garis binaan tak terhingga melalui dua titik. Gunakannya untuk menyusun lakaran tanpa memisahkan profil.",
|
||||
"help.sketch.construction_line.angle": "Cipta garis binaan tak terhingga pada sudut daripada paksi X lakaran. Pilih titik dan taip sudutnya, atau pilih arahnya.",
|
||||
"help.sketch.construction_line.bisector": "Pilih dua garis lakaran lurus yang bersilang, kemudian pilih salah satu pembahagi dua sudut. Hasilnya mengikut garis sumbernya.",
|
||||
"help.sketch.construction_line.horizontal": "Cipta garis binaan mendatar tak terhingga melalui satu titik. Kekangan mendatarnya kekal apabila disunting kemudian.",
|
||||
"help.sketch.construction_line.parallel": "Pilih garis lakaran lurus, kemudian letakkan garis binaan tak terhingga yang selari. Klik kanan untuk memilih rujukan lain.",
|
||||
"help.sketch.construction_line.perpendicular": "Pilih garis lakaran lurus, kemudian letakkan garis binaan tak terhingga yang serenjang. Klik kanan untuk memilih rujukan lain.",
|
||||
"help.sketch.construction_line.tangent_at": "Cipta tangen pada satu titik pada bulatan atau lengkok bulat, atau pada titik hujung splin atau lengkung kon yang terbuka. Pilih lengkung itu, kemudian titik sentuhnya.",
|
||||
"help.sketch.construction_line.tangent_external": "Pilih bulatan atau lengkok bulat, kemudian satu titik luar dan satu penyelesaian tangen. Pratonton hanya menunjukkan titik sentuh pada lengkok yang dilukis.",
|
||||
"help.sketch.construction_line.tangent_two": "Pilih dua bulatan atau lengkok bulat, kemudian pilih tangen sepunya dalam pratonton. Titik sentuh mesti terletak pada lengkung yang dilukis.",
|
||||
"help.sketch.construction_line.vertical": "Cipta garis binaan menegak tak terhingga melalui satu titik. Kekangan menegaknya kekal apabila disunting kemudian.",
|
||||
"prefs.persistent_ribbon_pressed": "Serlahkan arahan reben yang aktif",
|
||||
"prefs.persistent_ribbon_pressed_help": "Serlahkan arahan reben selagi kesannya dihidupkan. Matikan untuk mengekalkan rupa neutral dan bantuan arahan yang terdahulu.",
|
||||
"prefs.sketch_construction_lines": "Alat garis binaan lanjutan",
|
||||
"prefs.sketch_open_look_at": "Putar menghadap lakaran apabila dibuka",
|
||||
"prefs.sketch_open_look_at_note": "Mati: mencipta atau menyunting lakaran membiarkan pandangan seperti sedia ada. Pandang Tepat menghadapkan pandangan ke lakaran apabila anda mahu.",
|
||||
"recovery.discard_all": "Buang semua",
|
||||
"recovery.discard_all_detail": "Tindakan ini tidak boleh dibuat asal.",
|
||||
"recovery.discard_all_title": "Buang {count} simpanan automatik?",
|
||||
"recovery.discard_all_title_one": "Buang 1 simpanan automatik?",
|
||||
"ui.construction.angle_hint": "Garis binaan: klik satu titik, kemudian taip sudut atau pilih arah",
|
||||
"ui.construction.bisector_hint": "Pembahagi dua sudut: pilih dua garis lurus yang bersilang, kemudian penyelesaian yang dikehendaki",
|
||||
"ui.construction.common_hint": "Tangen sepunya: pilih dua bulatan/lengkok, kemudian penyelesaian yang dikehendaki",
|
||||
"ui.construction.contact_hint": "Tangen: pilih bulatan/lengkok atau splin/lengkung kon terbuka, kemudian titik sentuh (lengkung bentuk bebas: titik hujung)",
|
||||
"ui.construction.external_hint": "Tangen: pilih bulatan atau lengkok, satu titik luar, kemudian penyelesaian yang dikehendaki",
|
||||
"ui.construction.free_hint": "Garis binaan: klik dua titik; klik kanan untuk mula semula",
|
||||
"ui.construction.point_hint": "Garis binaan: klik titik yang akan dilalui",
|
||||
"ui.construction.reference_hint": "Garis binaan: pilih garis lurus, kemudian satu titik; klik kanan untuk menukar rujukan"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"nb": {
|
||||
"browser.calibrate_click_a": "Klikk på bildet for punkt A, eller skriv inn koordinatene.",
|
||||
"browser.calibrate_click_b": "Klikk på bildet for punkt B, eller skriv inn koordinatene.",
|
||||
"help.sketch.construction_line": "Opprett en uendelig hjelpelinje gjennom to punkter. Bruk den til å legge opp en skisse uten å dele opp profilene.",
|
||||
"help.sketch.construction_line.angle": "Opprett en uendelig hjelpelinje i en vinkel fra skissens X-akse. Velg punktet og skriv inn vinkelen, eller velg retningen.",
|
||||
"help.sketch.construction_line.bisector": "Velg to rette skisselinjer som krysser hverandre, og velg deretter en av vinkelhalveringslinjene. Resultatet følger kildelinjene sine.",
|
||||
"help.sketch.construction_line.horizontal": "Opprett en uendelig vannrett hjelpelinje gjennom et punkt. Den vannrette bindingen følger med ved senere endringer.",
|
||||
"help.sketch.construction_line.parallel": "Velg en rett skisselinje, og plasser deretter en parallell uendelig hjelpelinje. Høyreklikk for å velge en annen referanse.",
|
||||
"help.sketch.construction_line.perpendicular": "Velg en rett skisselinje, og plasser deretter en vinkelrett uendelig hjelpelinje. Høyreklikk for å velge en annen referanse.",
|
||||
"help.sketch.construction_line.tangent_at": "Opprett en tangent i et punkt på en sirkel eller sirkelbue, eller i endepunktet på en åpen spline eller kjeglesnittkurve. Velg kurven, og deretter berøringspunktet.",
|
||||
"help.sketch.construction_line.tangent_external": "Velg en sirkel eller sirkelbue, deretter et eksternt punkt og en tangentløsning. Forhåndsvisningene viser bare berøringspunkter på den tegnede buen.",
|
||||
"help.sketch.construction_line.tangent_two": "Velg to sirkler eller sirkelbuer, og velg deretter en felles tangent i forhåndsvisningen. Berøringspunktene må ligge på de tegnede kurvene.",
|
||||
"help.sketch.construction_line.vertical": "Opprett en uendelig loddrett hjelpelinje gjennom et punkt. Den loddrette bindingen følger med ved senere endringer.",
|
||||
"prefs.persistent_ribbon_pressed": "Fremhev aktive kommandoer på båndet",
|
||||
"prefs.persistent_ribbon_pressed_help": "Fremhev kommandoer på båndet mens effektene deres er slått på. Slå av for å beholde det forrige nøytrale utseendet og den forrige kommandohjelpen.",
|
||||
"prefs.sketch_construction_lines": "Utvidede hjelpelinjeverktøy",
|
||||
"prefs.sketch_open_look_at": "Orienter mot skissen når den åpnes",
|
||||
"prefs.sketch_open_look_at_note": "Av: å opprette eller redigere en skisse lar visningen stå som den er. Orienter mot vender visningen mot skissen når du vil.",
|
||||
"recovery.discard_all": "Forkast alle",
|
||||
"recovery.discard_all_detail": "Dette kan ikke angres.",
|
||||
"recovery.discard_all_title": "Forkast {count} automatiske lagringer?",
|
||||
"recovery.discard_all_title_one": "Forkast 1 automatisk lagring?",
|
||||
"ui.construction.angle_hint": "Hjelpelinje: klikk på et punkt, og skriv deretter inn en vinkel eller velg en retning",
|
||||
"ui.construction.bisector_hint": "Vinkelhalveringslinje: velg to rette linjer som krysser hverandre, og deretter ønsket løsning",
|
||||
"ui.construction.common_hint": "Felles tangent: velg to sirkler/buer, og deretter ønsket løsning",
|
||||
"ui.construction.contact_hint": "Tangent: velg en sirkel/bue eller åpen spline/kjeglesnittkurve, og deretter berøringspunktet (frie kurver: endepunkter)",
|
||||
"ui.construction.external_hint": "Tangent: velg en sirkel eller bue, et eksternt punkt, og deretter ønsket løsning",
|
||||
"ui.construction.free_hint": "Hjelpelinje: klikk på to punkter; høyreklikk for å starte på nytt",
|
||||
"ui.construction.point_hint": "Hjelpelinje: klikk på punktet linjen skal gå gjennom",
|
||||
"ui.construction.reference_hint": "Hjelpelinje: velg en rett linje, og deretter et punkt; høyreklikk for å bytte referanse"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"zh-TW": {
|
||||
"browser.calibrate_click_a": "按一下影像以指定點 A,或輸入其座標。",
|
||||
"browser.calibrate_click_b": "按一下影像以指定點 B,或輸入其座標。",
|
||||
"help.sketch.construction_line": "建立通過兩點的無限長建構直線。可用來規劃草圖佈局,而不會分割輪廓。",
|
||||
"help.sketch.construction_line.angle": "建立與草圖 X 軸夾指定角度的無限長建構直線。請選取點並輸入角度,或選取其方向。",
|
||||
"help.sketch.construction_line.bisector": "選取兩條相交的草圖直線,再選擇其中一條角平分線。結果會跟隨其來源直線。",
|
||||
"help.sketch.construction_line.horizontal": "建立通過某一點的水平無限長建構直線。其水平拘束會在後續編輯中持續維持。",
|
||||
"help.sketch.construction_line.parallel": "選取一條草圖直線,再放置與其平行的無限長建構直線。按右鍵可選擇其他參考。",
|
||||
"help.sketch.construction_line.perpendicular": "選取一條草圖直線,再放置與其垂直的無限長建構直線。按右鍵可選擇其他參考。",
|
||||
"help.sketch.construction_line.tangent_at": "在圓或圓弧上的某一點,或在開放雲形線或圓錐曲線的端點建立切線。請先選取曲線,再選取其切點。",
|
||||
"help.sketch.construction_line.tangent_external": "選取圓或圓弧,再選取外部點與一個切線解。預覽只會顯示位於所繪圓弧上的切點。",
|
||||
"help.sketch.construction_line.tangent_two": "選取兩個圓或圓弧,再於預覽中選擇一條公切線。切點必須位於所繪曲線上。",
|
||||
"help.sketch.construction_line.vertical": "建立通過某一點的鉛直無限長建構直線。其鉛直拘束會在後續編輯中持續維持。",
|
||||
"prefs.persistent_ribbon_pressed": "醒目顯示作用中的功能區命令",
|
||||
"prefs.persistent_ribbon_pressed_help": "在功能區命令的效果開啟期間醒目顯示這些命令。關閉可維持先前的中性外觀與命令說明。",
|
||||
"prefs.sketch_construction_lines": "延伸建構直線工具",
|
||||
"prefs.sketch_open_look_at": "開啟草圖時轉向正對草圖",
|
||||
"prefs.sketch_open_look_at_note": "關閉:建立或編輯草圖時維持目前的檢視。需要時可用「注視」正對草圖。",
|
||||
"recovery.discard_all": "全部捨棄",
|
||||
"recovery.discard_all_detail": "此操作無法復原。",
|
||||
"recovery.discard_all_title": "捨棄 {count} 個自動儲存檔?",
|
||||
"recovery.discard_all_title_one": "捨棄 1 個自動儲存檔?",
|
||||
"ui.construction.angle_hint": "建構直線:按一下一點,再輸入角度或選取方向",
|
||||
"ui.construction.bisector_hint": "角平分線:選取兩條相交的直線,再選取所需的解",
|
||||
"ui.construction.common_hint": "公切線:選取兩個圓/圓弧,再選取所需的解",
|
||||
"ui.construction.contact_hint": "切線:選取圓/圓弧或開放雲形線/圓錐曲線,再選取切點(自由曲線:端點)",
|
||||
"ui.construction.external_hint": "切線:選取圓或圓弧、一個外部點,再選取所需的解",
|
||||
"ui.construction.free_hint": "建構直線:按一下兩點;按右鍵可重新開始",
|
||||
"ui.construction.point_hint": "建構直線:按一下要通過的點",
|
||||
"ui.construction.reference_hint": "建構直線:選取一條直線,再選取一點;按右鍵可變更參考"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"tr": {
|
||||
"browser.calibrate_click_a": "A Noktası için görüntüye tıklayın veya koordinatlarını yazın.",
|
||||
"browser.calibrate_click_b": "B Noktası için görüntüye tıklayın veya koordinatlarını yazın.",
|
||||
"help.sketch.construction_line": "İki noktadan geçen sonsuz bir yardımcı doğru oluşturun. Profilleri bölmeden bir eskizi kurgulamak için kullanın.",
|
||||
"help.sketch.construction_line.angle": "Eskizin X ekseninden belirli bir açıyla sonsuz bir yardımcı doğru oluşturun. Noktayı seçip açıyı yazın veya yönünü seçin.",
|
||||
"help.sketch.construction_line.bisector": "Kesişen iki düz eskiz çizgisini seçin, ardından iki açıortaydan birini seçin. Sonuç, kaynak çizgilerini izler.",
|
||||
"help.sketch.construction_line.horizontal": "Bir noktadan geçen sonsuz yatay bir yardımcı doğru oluşturun. Yatay kısıtlaması sonraki düzenlemelerde de korunur.",
|
||||
"help.sketch.construction_line.parallel": "Düz bir eskiz çizgisi seçin, ardından paralel bir sonsuz yardımcı doğru yerleştirin. Başka bir referans seçmek için sağ tıklayın.",
|
||||
"help.sketch.construction_line.perpendicular": "Düz bir eskiz çizgisi seçin, ardından dik bir sonsuz yardımcı doğru yerleştirin. Başka bir referans seçmek için sağ tıklayın.",
|
||||
"help.sketch.construction_line.tangent_at": "Bir daire veya dairesel yay üzerindeki bir noktada ya da açık bir spline veya konik eğrinin uç noktasında teğet oluşturun. Önce eğriyi, sonra değme noktasını seçin.",
|
||||
"help.sketch.construction_line.tangent_external": "Bir daire veya dairesel yay seçin, ardından bir dış nokta ve bir teğet çözümü seçin. Önizlemeler yalnızca çizilen yay üzerindeki değme noktalarını gösterir.",
|
||||
"help.sketch.construction_line.tangent_two": "İki daire veya dairesel yay seçin, ardından önizlemede ortak bir teğet seçin. Değme noktaları çizilen eğriler üzerinde olmalıdır.",
|
||||
"help.sketch.construction_line.vertical": "Bir noktadan geçen sonsuz dikey bir yardımcı doğru oluşturun. Dikey kısıtlaması sonraki düzenlemelerde de korunur.",
|
||||
"prefs.persistent_ribbon_pressed": "Etkin şerit komutlarını vurgula",
|
||||
"prefs.persistent_ribbon_pressed_help": "Şerit komutlarını, etkileri açık olduğu sürece vurgula. Önceki nötr görünümü ve komut yardımını korumak için kapatın.",
|
||||
"prefs.sketch_construction_lines": "Genişletilmiş yardımcı doğru araçları",
|
||||
"prefs.sketch_open_look_at": "Eskiz açıldığında görünümü ona doğru döndür",
|
||||
"prefs.sketch_open_look_at_note": "Kapalı: eskiz oluşturmak veya düzenlemek görünümü olduğu gibi bırakır. Bak komutu, istediğinizde görünümü eskize çevirir.",
|
||||
"recovery.discard_all": "Tümünü at",
|
||||
"recovery.discard_all_detail": "Bu işlem geri alınamaz.",
|
||||
"recovery.discard_all_title": "{count} otomatik kayıt atılsın mı?",
|
||||
"recovery.discard_all_title_one": "1 otomatik kayıt atılsın mı?",
|
||||
"ui.construction.angle_hint": "Yardımcı doğru: bir noktaya tıklayın, ardından bir açı yazın veya bir yön seçin",
|
||||
"ui.construction.bisector_hint": "Açıortay: kesişen iki düz çizgiyi, ardından istenen çözümü seçin",
|
||||
"ui.construction.common_hint": "Ortak teğet: iki daire/yay, ardından istenen çözümü seçin",
|
||||
"ui.construction.contact_hint": "Teğet: bir daire/yay veya açık spline/konik eğri, ardından değme noktasını seçin (serbest biçimli eğriler: uç noktalar)",
|
||||
"ui.construction.external_hint": "Teğet: bir daire veya yay, bir dış nokta, ardından istenen çözümü seçin",
|
||||
"ui.construction.free_hint": "Yardımcı doğru: iki noktaya tıklayın; yeniden başlamak için sağ tıklayın",
|
||||
"ui.construction.point_hint": "Yardımcı doğru: geçeceği noktaya tıklayın",
|
||||
"ui.construction.reference_hint": "Yardımcı doğru: düz bir çizgi, ardından bir nokta seçin; referansı değiştirmek için sağ tıklayın"
|
||||
}
|
||||
}
|
||||
@@ -775,6 +775,8 @@
|
||||
"at the face": "на лицу",
|
||||
"bom.group_children": "Подделови у реду свог дела",
|
||||
"browser.body_name": "Тело{number}",
|
||||
"browser.calibrate_click_a": "Кликните слику да зададете тачку A или упишите њене координате.",
|
||||
"browser.calibrate_click_b": "Кликните слику да зададете тачку B или упишите њене координате.",
|
||||
"browser.calibrate_title": "Калибриши: {name}",
|
||||
"browser.capture_many": "Померене компоненте без забележеног положаја: {count}.",
|
||||
"browser.capture_note": "Забележи задржава нови положај; Врати га враћа назад.",
|
||||
@@ -1399,6 +1401,16 @@
|
||||
"help.occurrence.move": "Помера део склопа само дуж слобода које дозвољавају његови спојеви. Изаберите лице дела, а затим превуците доступну стрелицу или лук.",
|
||||
"help.parameters.change": "Отвара табелу именованих вредности и израза који управљају котама дизајна. Користите је да промените меру на једном месту, а све операције које је користе је прате.",
|
||||
"help.pointcloud.insert": "Уметните PLY, XYZ или PTS скенирање као облак тачака поред модела. Користите га као референцу при изради модела на основу измерених података.",
|
||||
"help.sketch.construction_line": "Направите бесконачну помоћну праву кроз две тачке. Користите је да распоредите скицу без раздвајања профила.",
|
||||
"help.sketch.construction_line.angle": "Направите бесконачну помоћну праву под углом од осе X скице. Изаберите тачку и упишите угао или изаберите смер.",
|
||||
"help.sketch.construction_line.bisector": "Изаберите две праве линије скице које се секу, па једну од две симетрале угла. Резултат прати изворне линије.",
|
||||
"help.sketch.construction_line.horizontal": "Направите бесконачну хоризонталну помоћну праву кроз тачку. Њено ограничење хоризонталности остаје на снази при каснијим изменама.",
|
||||
"help.sketch.construction_line.parallel": "Изаберите праву линију скице, па поставите паралелну бесконачну помоћну праву. Десним кликом изаберите другу референцу.",
|
||||
"help.sketch.construction_line.perpendicular": "Изаберите праву линију скице, па поставите нормалну бесконачну помоћну праву. Десним кликом изаберите другу референцу.",
|
||||
"help.sketch.construction_line.tangent_at": "Направите тангенту у тачки на кругу или кружном луку, или у крајњој тачки отвореног сплајна или конике. Изаберите криву, па тачку додира.",
|
||||
"help.sketch.construction_line.tangent_external": "Изаберите круг или кружни лук, па спољашњу тачку и решење тангенте. Прегледи приказују само тачке додира на нацртаном луку.",
|
||||
"help.sketch.construction_line.tangent_two": "Изаберите два круга или кружна лука, па у прегледу изаберите заједничку тангенту. Тачке додира морају лежати на нацртаним кривама.",
|
||||
"help.sketch.construction_line.vertical": "Направите бесконачну вертикалну помоћну праву кроз тачку. Њено ограничење вертикалности остаје на снази при каснијим изменама.",
|
||||
"help.sketch.equation_curve": "Црта криву из формула: x(t) и y(t), или y = f(x), у задатом опсегу. Формуле могу да користе параметре и крива их прати: користите је за синусоиду, спиралу или евольвенту зупца зупчаника.",
|
||||
"help.solid.align": "Помера изабрана тела тако да се тачка, лице или раван на њима поклопи са тачком, лицем или равни негде другде. Користите га да поставите део уз други без мерења одмака.",
|
||||
"help.solid.chamfer": "Одсеца изабране ивице равним скошењем задатог растојања. Користите га да ублажите оштре ивице или олакшате увођење чивије или завртња.",
|
||||
@@ -1935,6 +1947,8 @@
|
||||
"prefs.origin_plane_names": "Називи равни исходишта",
|
||||
"prefs.origin_plane_names_note": "Називи приказани у стаблу модела, дијалозима и листама. {top} је XY, {front} је XZ, а {right} је YZ, са Z нагоре. Команде, скрипте и сачувани дизајни задржавају XY, XZ и YZ.",
|
||||
"prefs.overall_speed": "Укупна брзина",
|
||||
"prefs.persistent_ribbon_pressed": "Истакни активне команде на траци",
|
||||
"prefs.persistent_ribbon_pressed_help": "Истиче команде на траци док су њихови ефекти укључени. Искључите да бисте задржали претходни неутрални изглед и помоћ за команде.",
|
||||
"prefs.perspective_fov": "Видно поље",
|
||||
"prefs.placement": "Постављање",
|
||||
"prefs.prefer_free_sketch_points": "Дај предност покретним тачкама скице када се тачке преклапају",
|
||||
@@ -1984,11 +1998,14 @@
|
||||
"prefs.sketch_align_points_note": "Током цртања тачка се истовремено поравнава хоризонтално са једном тачком скице и вертикално са другом, уз тачкасти водич, а клик задржава оба поравнања. Искључено: само са претходном тачком, по једна оса.",
|
||||
"prefs.sketch_conflict_colour": "Геометрија у сукобу у боји сукоба",
|
||||
"prefs.sketch_conflict_colour_note": "Док су ограничења скице у сукобу, криве и тачке које држи ограничење које не успева исцртавају се такође у боји сукоба, а не само његов симбол или кота. Искључено: задржавају уобичајене боје.",
|
||||
"prefs.sketch_construction_lines": "Проширени алати за помоћне праве",
|
||||
"prefs.sketch_exit_view": "Када се скица заврши",
|
||||
"prefs.sketch_exit_view.keep": "Задржи тренутни приказ",
|
||||
"prefs.sketch_exit_view.previous": "Врати се на претходни приказ",
|
||||
"prefs.sketch_exit_view.unless_rotated": "Врати се осим ако је ротирано",
|
||||
"prefs.sketch_exit_view_note": "Куда приказ иде након Заврши скицу, Откажи или двоструког Esc.",
|
||||
"prefs.sketch_open_look_at": "Окрени камеру према скици када се отвори",
|
||||
"prefs.sketch_open_look_at_note": "Искључено: када направите или измените скицу, приказ остаје онакав какав јесте. Погледај окреће приказ према скици када то желите.",
|
||||
"prefs.sketch_recent_snap": "Аутоматски привлачи на недавне центре и средишње тачке",
|
||||
"prefs.sketch_recent_snap_note": "Током цртања показивач се привлачи тамо где се секу водичи двају последњих центара кругова или средишњих тачака правих ивица. Искључено: ти водичи служе само за приказ и показивач се не помера.",
|
||||
"prefs.sketch_selection": "Скица и избор",
|
||||
@@ -2064,6 +2081,10 @@
|
||||
"properties.title": "Својства: {name}",
|
||||
"recovery.days_ago": "пре {count} дана",
|
||||
"recovery.detail": "{file} · операција: {features} · аутоматски сачувано {when}",
|
||||
"recovery.discard_all": "Одбаци све",
|
||||
"recovery.discard_all_detail": "Ово се не може поништити.",
|
||||
"recovery.discard_all_title": "Одбацити аутоматска чувања ({count})?",
|
||||
"recovery.discard_all_title_one": "Одбацити 1 аутоматско чување?",
|
||||
"recovery.hours_ago": "пре {count} h",
|
||||
"recovery.just_now": "управо сада",
|
||||
"recovery.min_ago": "пре {count} min",
|
||||
@@ -3983,6 +4004,14 @@
|
||||
"ui.constant": "Константно",
|
||||
"ui.constraints_conflict": "Ограничења су у сукобу",
|
||||
"ui.construction": "Помоћна",
|
||||
"ui.construction.angle_hint": "Помоћна права: кликните тачку, па упишите угао или изаберите смер",
|
||||
"ui.construction.bisector_hint": "Симетрала: изаберите две праве линије које се секу, па жељено решење",
|
||||
"ui.construction.common_hint": "Заједничка тангента: изаберите два круга или лука, па жељено решење",
|
||||
"ui.construction.contact_hint": "Тангента: изаберите круг/лук или отворени сплајн/конику, па тачку додира (криве слободног облика: крајње тачке)",
|
||||
"ui.construction.external_hint": "Тангента: изаберите круг или лук, спољашњу тачку, па жељено решење",
|
||||
"ui.construction.free_hint": "Помоћна права: кликните две тачке; десним кликом почните испочетка",
|
||||
"ui.construction.point_hint": "Помоћна права: кликните тачку кроз коју пролази",
|
||||
"ui.construction.reference_hint": "Помоћна права: изаберите праву линију, па тачку; десним кликом промените референцу",
|
||||
"ui.construction.ribbon_label": "Пом. праве",
|
||||
"ui.context_menu": "Контекстни мени",
|
||||
"ui.continuity_g0": "G0 прегиб",
|
||||
|
||||
@@ -1928,6 +1928,9 @@
|
||||
"prefs.max_file_size_step": "Tệp STEP và tệp nhập",
|
||||
"prefs.max_file_size_web": "Ứng dụng web mở tệp STEP và tệp nhập tối đa 512 MB, thiết kế tối đa 256 MB: thẻ trình duyệt không chứa được tệp lớn hơn. Ứng dụng máy tính cho phép tăng các giới hạn này.",
|
||||
"prefs.megabytes": "MB",
|
||||
"prefs.menu_grid": "Biểu tượng lớn",
|
||||
"prefs.menu_list": "Danh sách",
|
||||
"prefs.menus": "Menu",
|
||||
"prefs.minutes": "phút",
|
||||
"prefs.minutes_suffix": " phút",
|
||||
"prefs.mouse": "Chuột và bàn di cảm ứng",
|
||||
@@ -2069,6 +2072,8 @@
|
||||
"prefs.view_confirm_corner": "Nút Đồng ý và Hủy bỏ ở góc khung nhìn",
|
||||
"prefs.view_confirm_corner_note": "Khi hộp thoại hoặc môi trường phác thảo đang mở, dấu tích và dấu chéo ở góc trên bên phải khung nhìn có chức năng tương tự nút Đồng ý và Hủy bỏ của hộp thoại, hoặc để hoàn tất và hủy phác thảo. Khối điều hướng góc nhìn sẽ dời xuống bên dưới chúng.",
|
||||
"prefs.viewport": "Khung nhìn 3D",
|
||||
"prefs.viewport_analysis_status": "Hiện trạng thái phân tích trong khung nhìn",
|
||||
"prefs.viewport_analysis_status_help": "Hiện các phân tích đang bật phía trên khung nhìn và mở chú giải từ từng mục. Esc tắt phân tích mới nhất. Tắt tùy chọn để khôi phục chú giải ở góc và hành vi Esc trước đây.",
|
||||
"prefs.viewport_background": "Nền",
|
||||
"prefs.viewport_background_colour": "Màu",
|
||||
"prefs.viewport_background_theme": "Dải màu của chủ đề",
|
||||
@@ -4479,6 +4484,7 @@
|
||||
"ui.revolve": "Xoay tròn tạo khối",
|
||||
"ui.ribbon_exit": "Thoát",
|
||||
"ui.ribbon_labels": "Hiển thị văn bản bên dưới biểu tượng dải băng",
|
||||
"ui.ribbon_menus_skip_pinned": "Không lặp lại các lệnh đã ghim trong menu thả xuống",
|
||||
"ui.ribbon_pin_footer": "Các lệnh đã ghim hiển thị trên ruy-băng. Rê chuột lên một hàng để ghim hoặc bỏ ghim.",
|
||||
"ui.ribbon_workspace": "Không gian làm việc",
|
||||
"ui.right": "Phải (Hình chiếu cạnh)",
|
||||
@@ -4642,6 +4648,8 @@
|
||||
"ui.view_centre": "Tâm góc nhìn",
|
||||
"ui.viewport_status_clear": "Tắt {name}",
|
||||
"ui.viewport_status_clear_hint": "Tắt {name} · Esc",
|
||||
"ui.viewport_status_edges": "{count} cạnh",
|
||||
"ui.viewport_status_from": "từ {direction}",
|
||||
"ui.viewport_status_key": "Hiển thị chú giải cho {name}",
|
||||
"ui.viewport_status_minimum_radius": "Bán kính nhỏ nhất",
|
||||
"ui.viewport_status_pairs": "{count} cặp",
|
||||
|
||||
@@ -1928,6 +1928,9 @@
|
||||
"prefs.max_file_size_step": "STEP 和导入文件",
|
||||
"prefs.max_file_size_web": "网页版可打开最大 512 MB 的 STEP 和导入文件,以及最大 256 MB 的设计:浏览器标签页无法容纳更大的文件。桌面版可提高这些上限。",
|
||||
"prefs.megabytes": "MB",
|
||||
"prefs.menu_grid": "大图标",
|
||||
"prefs.menu_list": "列表",
|
||||
"prefs.menus": "菜单",
|
||||
"prefs.minutes": "分钟",
|
||||
"prefs.minutes_suffix": " min",
|
||||
"prefs.mouse": "鼠标与触控板",
|
||||
@@ -2069,6 +2072,8 @@
|
||||
"prefs.view_confirm_corner": "在视图角落显示确定和取消",
|
||||
"prefs.view_confirm_corner_note": "打开对话框或草图时,视图右上角的勾号和叉号与对话框的确定和取消作用相同,或完成和取消草图。视图导航器会下移到它们下方。",
|
||||
"prefs.viewport": "视口",
|
||||
"prefs.viewport_analysis_status": "显示视口分析状态",
|
||||
"prefs.viewport_analysis_status_help": "在视口上方显示启用的分析,从各项目打开图例,并按 Esc 关闭最近启用的分析。关闭此选项可恢复角落图例和原来的 Esc 行为。",
|
||||
"prefs.viewport_background": "背景",
|
||||
"prefs.viewport_background_colour": "颜色",
|
||||
"prefs.viewport_background_theme": "主题渐变",
|
||||
@@ -4479,6 +4484,7 @@
|
||||
"ui.revolve": "旋转",
|
||||
"ui.ribbon_exit": "退出",
|
||||
"ui.ribbon_labels": "显示功能区图标下的文字",
|
||||
"ui.ribbon_menus_skip_pinned": "下拉菜单中不再列出已固定的命令",
|
||||
"ui.ribbon_pin_footer": "固定的命令显示在功能区中。将指针悬停在行上可固定或取消固定。",
|
||||
"ui.ribbon_workspace": "工作区",
|
||||
"ui.right": "右视",
|
||||
@@ -4642,6 +4648,8 @@
|
||||
"ui.view_centre": "视图中心",
|
||||
"ui.viewport_status_clear": "关闭{name}",
|
||||
"ui.viewport_status_clear_hint": "关闭{name} · Esc",
|
||||
"ui.viewport_status_edges": "{count} 条边",
|
||||
"ui.viewport_status_from": "从 {direction}",
|
||||
"ui.viewport_status_key": "显示{name}的图例",
|
||||
"ui.viewport_status_minimum_radius": "最小半径",
|
||||
"ui.viewport_status_pairs": "{count} 对",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"error": "#fff0ca",
|
||||
"field": "#100b04",
|
||||
"form_accent": "#ca892b",
|
||||
"form_dim": "#9b6922",
|
||||
"form_dim": "#ad7a2c",
|
||||
"form_error": "#fff0ca",
|
||||
"form_error_fill": "#46300f",
|
||||
"form_fill": "#e19e45",
|
||||
@@ -180,7 +180,7 @@
|
||||
"surface_soft": "#2a1e0b",
|
||||
"tab_on_fill_dark": "#170d03",
|
||||
"text": "#f3ca87",
|
||||
"text_dim": "#9b6922",
|
||||
"text_dim": "#ad7a2c",
|
||||
"timeline": "#090602",
|
||||
"timeline_item": "#1d1406",
|
||||
"timeline_item_error": "#46300f",
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
"form_dim": "#bdae93",
|
||||
"form_primary": "#83a598",
|
||||
"form_error": "#fb4934",
|
||||
"form_error_fill": "#702b28",
|
||||
"form_error_fill": "#5f2320",
|
||||
"form_success": "#b8bb26",
|
||||
"form_success_fill": "#343a28",
|
||||
"dialog_body": "#32302f",
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"form_accent": "#cfa7c7",
|
||||
"form_dim": "#a4b0c2",
|
||||
"form_error": "#bf616a",
|
||||
"form_error_fill": "#4b3d48",
|
||||
"form_error_fill": "#352b33",
|
||||
"form_fill": "#e7bfe0",
|
||||
"form_ink": "#eceff4",
|
||||
"form_primary": "#88c0d0",
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
"surface_soft": "#363332",
|
||||
"tab_on_fill_dark": "#000000",
|
||||
"text": "#abb2bf",
|
||||
"text_dim": "#7f848e",
|
||||
"text_dim": "#90959f",
|
||||
"timeline": "#1e2227",
|
||||
"timeline_item": "#323842",
|
||||
"timeline_item_error": "#5c343d",
|
||||
|
||||
@@ -59,12 +59,12 @@
|
||||
"form_accent": "#6b70c3",
|
||||
"form_dim": "#657b83",
|
||||
"form_error": "#dc322f",
|
||||
"form_error_fill": "#eac4b4",
|
||||
"form_error_fill": "#f7ddd3",
|
||||
"form_fill": "#a2a9f8",
|
||||
"form_ink": "#586e75",
|
||||
"form_primary": "#268bd2",
|
||||
"form_soft": "#e4ded4",
|
||||
"form_success": "#859900",
|
||||
"form_success": "#5f6e00",
|
||||
"form_success_fill": "#dcdbb1",
|
||||
"form_tab_ink": "#565a9d",
|
||||
"glyph_bg": "#e3ddc9",
|
||||
@@ -130,6 +130,8 @@
|
||||
"mesh_fill": "#d7aa3c",
|
||||
"mesh_ink": "#7b5d00",
|
||||
"mesh_soft": "#e9e0c4",
|
||||
"nav_tip": "#073642",
|
||||
"nav_tip_ink": "#eee8d5",
|
||||
"on_accent": "#ffffff",
|
||||
"origin_plane": "#b5890028",
|
||||
"origin_plane_edge": "#b5890088",
|
||||
@@ -179,7 +181,7 @@
|
||||
"surface_ink": "#a83e12",
|
||||
"surface_soft": "#ebdbc6",
|
||||
"tab_on_fill_dark": "#fdf6e3",
|
||||
"text": "#586e75",
|
||||
"text": "#536970",
|
||||
"text_dim": "#657b83",
|
||||
"timeline": "#e3ddc9",
|
||||
"timeline_item": "#e3ddc9",
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"form_ink": "#000000",
|
||||
"form_primary": "#000080",
|
||||
"form_soft": "#bbb1bb",
|
||||
"form_success": "#008000",
|
||||
"form_success": "#006000",
|
||||
"form_success_fill": "#c0dcc0",
|
||||
"form_tab_ink": "#7d007d",
|
||||
"glyph_bg": "#ffffff",
|
||||
@@ -130,6 +130,10 @@
|
||||
"mesh_fill": "#808000",
|
||||
"mesh_ink": "#4c4c00",
|
||||
"mesh_soft": "#bbbbb1",
|
||||
"nav_press": "#a0a0a0",
|
||||
"nav_tip": "#ffffe1",
|
||||
"nav_tip_ink": "#000000",
|
||||
"nav_tip_key_border": "#808080",
|
||||
"on_accent": "#ffffff",
|
||||
"origin_plane": "#ffff005e",
|
||||
"origin_plane_edge": "#808000c8",
|
||||
@@ -180,7 +184,7 @@
|
||||
"surface_soft": "#bbb6b1",
|
||||
"tab_on_fill_dark": "#000000",
|
||||
"text": "#000000",
|
||||
"text_dim": "#808080",
|
||||
"text_dim": "#606060",
|
||||
"timeline": "#c0c0c0",
|
||||
"timeline_item": "#c0c0c0",
|
||||
"timeline_item_error": "#ffffc0",
|
||||
|
||||
@@ -77,6 +77,7 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.section = Some((origin, normal));
|
||||
s.section_hatch = bool_(p, "hatch").unwrap_or(true);
|
||||
s.status_slot(crate::status_items::StatusId::Section, true);
|
||||
s.status_activated(crate::status_items::StatusId::Section);
|
||||
s.revision += 1;
|
||||
let mut section = json!({"origin": origin, "normal": normal, "hatch": s.section_hatch});
|
||||
if let Some(i) = axis {
|
||||
|
||||
@@ -320,6 +320,51 @@ mod tests {
|
||||
serde_json::from_value::<crate::status_items::StatusItems>(s.execute("view.status_items", &json!({})).unwrap()).unwrap().items
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_status_uses_activation_not_row_order_and_filters_unpresented_effects() {
|
||||
use crate::status_items::StatusId::{Continuity, Section, SurfaceAnalysis};
|
||||
let mut s = Session::default();
|
||||
s.execute("inspect.zebra", &json!({})).unwrap();
|
||||
s.execute("inspect.section", &json!({"plane":"XY"})).unwrap();
|
||||
s.execute("inspect.draft", &json!({"angle":7})).unwrap();
|
||||
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), [SurfaceAnalysis, Section]);
|
||||
assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity, Section]).unwrap().command, "inspect.draft");
|
||||
s.execute("inspect.section", &json!({"plane":"XZ"})).unwrap();
|
||||
assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity, Section]).unwrap().id, Section);
|
||||
let before = (s.revision, s.undo.len(), s.doc.to_json(), s.status_activation.clone());
|
||||
assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity]).unwrap().command, "inspect.draft");
|
||||
assert_eq!((s.revision, s.undo.len(), s.doc.to_json(), s.status_activation.clone()), before);
|
||||
assert!(s.execute("inspect.zebra", &json!({"stripes":-1})).is_err());
|
||||
assert_eq!(s.status_activation, before.3);
|
||||
s.execute("inspect.draft", &json!({"clear":true})).unwrap();
|
||||
assert!(s.newest_status_item(&[SurfaceAnalysis, Continuity]).is_none());
|
||||
s.execute("inspect.zebra", &json!({})).unwrap();
|
||||
assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity, Section]).unwrap().command, "inspect.zebra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_status_tracks_independent_continuity_and_policy_normalisation() {
|
||||
use crate::status_items::StatusId::{Continuity, SurfaceAnalysis};
|
||||
for continuity_first in [false, true] {
|
||||
let mut s = Session::default();
|
||||
boxed(&mut s, 10.0);
|
||||
s.set_single_surface_analysis(false);
|
||||
let shading = ("inspect.zebra", json!({}));
|
||||
let seams = ("inspect.continuity", json!({"edges":[[5,0,0]],"samples":5}));
|
||||
for (cmd, params) in if continuity_first { [seams, shading] } else { [shading, seams] } {
|
||||
s.execute(cmd, ¶ms).unwrap();
|
||||
}
|
||||
let latest = if continuity_first { "inspect.zebra" } else { "inspect.continuity" };
|
||||
assert_eq!(s.newest_status_item(&[SurfaceAnalysis, Continuity]).unwrap().command, latest);
|
||||
s.set_single_surface_analysis(true);
|
||||
assert_eq!(s.newest_status_item(&[SurfaceAnalysis]).unwrap().command, latest);
|
||||
s.set_single_surface_analysis(false);
|
||||
let clear = s.newest_status_item(&[SurfaceAnalysis, Continuity]).unwrap().clear;
|
||||
s.execute(&clear.command, &json!({"clear":true})).unwrap();
|
||||
assert!(s.newest_status_item(&[SurfaceAnalysis, Continuity]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_items_zero_seam_continuity_never_leaves_a_phantom_winner() {
|
||||
use crate::status_items::StatusId;
|
||||
|
||||
@@ -472,8 +472,10 @@ pub struct Session {
|
||||
/// The seams `inspect.continuity` last measured, for the viewport to shade. View state: not
|
||||
/// part of the design and not an undo step.
|
||||
pub continuity: Vec<ContinuitySeam>,
|
||||
/// Transient activation order only; effect settings remain in their existing fields.
|
||||
/// Oldest active slot first; replacing an effect retains its row position.
|
||||
status_order: Vec<status_items::StatusId>,
|
||||
/// Successful source activations, independently of row order and sharing policy.
|
||||
status_activation: Vec<status_items::StatusId>,
|
||||
single_surface_analysis: bool,
|
||||
latest_analysis: Option<status_items::StatusId>,
|
||||
/// What is hidden or shown in the view (`browser.visibility`): saved with the design and an
|
||||
@@ -656,6 +658,7 @@ impl Session {
|
||||
analysis: None,
|
||||
continuity: Vec::new(),
|
||||
status_order: Vec::new(),
|
||||
status_activation: Vec::new(),
|
||||
single_surface_analysis: true,
|
||||
latest_analysis: None,
|
||||
checkpoints: Vec::new(),
|
||||
@@ -1506,6 +1509,7 @@ impl Session {
|
||||
analysis: None,
|
||||
continuity: Vec::new(),
|
||||
status_order: Vec::new(),
|
||||
status_activation: Vec::new(),
|
||||
single_surface_analysis: self.single_surface_analysis,
|
||||
latest_analysis: None,
|
||||
checkpoints: Vec::new(),
|
||||
|
||||
@@ -113,6 +113,14 @@ impl Session {
|
||||
}
|
||||
}
|
||||
self.single_surface_analysis = single;
|
||||
let shading = self.analysis.is_some();
|
||||
let continuity = !self.continuity.is_empty();
|
||||
let section = self.section.is_some();
|
||||
self.status_activation.retain(|source| match source {
|
||||
StatusId::SurfaceAnalysis => shading,
|
||||
StatusId::Continuity => continuity,
|
||||
StatusId::Section => section,
|
||||
});
|
||||
self.status_order.clear();
|
||||
for item in &before {
|
||||
let id = match item.id {
|
||||
@@ -160,6 +168,10 @@ impl Session {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.status_activation.retain(|id| *id == StatusId::Section || active(id));
|
||||
if let Some(id) = activated.filter(active) {
|
||||
self.status_activated(id);
|
||||
}
|
||||
self.latest_analysis = activated.filter(active).or(self.latest_analysis.filter(active)).or(remaining);
|
||||
self.status_slot(StatusId::SurfaceAnalysis, self.analysis.is_some() || (self.single_surface_analysis && !self.continuity.is_empty()));
|
||||
self.status_slot(StatusId::Continuity, !self.single_surface_analysis && !self.continuity.is_empty());
|
||||
@@ -212,9 +224,39 @@ impl Session {
|
||||
items
|
||||
}
|
||||
|
||||
/// Most recently activated effect among the caller's currently presented slots.
|
||||
/// Row position is deliberately independent: replacing an analysis retains its slot.
|
||||
/// Effects assigned directly by embedders have no activation record; their declared
|
||||
/// order provides a deterministic fallback without inventing session timestamps.
|
||||
pub fn newest_status_item(&self, eligible: &[StatusId]) -> Option<StatusItem> {
|
||||
let items = self.status_items();
|
||||
let find = |source: StatusId| {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| {
|
||||
eligible.contains(&item.id)
|
||||
&& match source {
|
||||
StatusId::SurfaceAnalysis => item.id == StatusId::SurfaceAnalysis && item.command != "inspect.continuity",
|
||||
StatusId::Continuity => item.command == "inspect.continuity",
|
||||
StatusId::Section => item.id == StatusId::Section,
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
};
|
||||
self.status_activation.iter().rev().find_map(|id| find(*id)).or_else(|| items.into_iter().rev().find(|item| eligible.contains(&item.id)))
|
||||
}
|
||||
|
||||
pub(crate) fn status_activated(&mut self, id: StatusId) {
|
||||
self.status_activation.retain(|source| *source != id);
|
||||
self.status_activation.push(id);
|
||||
}
|
||||
|
||||
pub(crate) fn status_slot(&mut self, id: StatusId, active: bool) {
|
||||
if !active {
|
||||
self.status_order.retain(|slot| *slot != id);
|
||||
if id == StatusId::Section {
|
||||
self.status_activation.retain(|source| *source != id);
|
||||
}
|
||||
} else if !self.status_order.contains(&id) {
|
||||
self.status_order.push(id);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ proc-macro2 = { workspace = true }
|
||||
# egui-winit's clipboard.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
arboard = { version = "3.6", default-features = false, features = ["image-data"] }
|
||||
lopdf = { version = "0.45", default-features = false }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||
# The offscreen viewport render test opens a device without a window.
|
||||
|
||||
@@ -30,11 +30,6 @@ const MAX_PICTURE_BYTES: u64 = 40 * 1024 * 1024;
|
||||
const MAX_TEXT_BYTES: u64 = 256 * 1024;
|
||||
/// Largest PDF sent as a document; a bigger one goes by reference.
|
||||
const MAX_PDF_BYTES: u64 = 12 * 1024 * 1024;
|
||||
/// Pages whose text is taken from a PDF, and the most characters of it kept.
|
||||
const MAX_PDF_PAGES: usize = 300;
|
||||
const MAX_PDF_CHARS: usize = 200_000;
|
||||
/// A PDF page's content may inflate to at most this (a compressed bomb is refused by the reader).
|
||||
const MAX_PDF_PAGE_BYTES: usize = 32 * 1024 * 1024;
|
||||
/// A picture's longer side is scaled down to this before it is sent.
|
||||
pub const MAX_SIDE: u32 = 2048;
|
||||
/// Largest picture sent, as it came or re-encoded: its base64 (a third larger) stays under 5 MB,
|
||||
@@ -93,11 +88,10 @@ pub enum Content {
|
||||
size: [u32; 2],
|
||||
},
|
||||
Text(Arc<String>),
|
||||
/// A PDF, sent whole as a document to an agent that takes them; to the others, its text.
|
||||
/// A PDF, sent whole as a document to an agent that takes them.
|
||||
Pdf {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
path: PathBuf,
|
||||
text: Option<Arc<PdfText>>,
|
||||
},
|
||||
/// A file the agent is pointed to, not given: what it is, its size and where it is.
|
||||
Reference {
|
||||
@@ -109,60 +103,6 @@ pub enum Content {
|
||||
Imported,
|
||||
}
|
||||
|
||||
/// What was taken out of a PDF: its page count and its text, without layout, pictures or fonts.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PdfText {
|
||||
pub pages: usize,
|
||||
pub text: String,
|
||||
/// The text stops short: of the pages or of the characters kept.
|
||||
pub cut: bool,
|
||||
}
|
||||
|
||||
/// The text of a PDF, from its pages in order (up to [`MAX_PDF_PAGES`] pages and
|
||||
/// [`MAX_PDF_CHARS`] characters). `None` when it can't be read at all (damaged, or a reader
|
||||
/// panic, which is caught); an encrypted or scanned one gives its page count and no text.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn pdf_text(bytes: &[u8]) -> Option<PdfText> {
|
||||
std::panic::catch_unwind(|| {
|
||||
let doc = lopdf::Document::load_mem(bytes).ok()?;
|
||||
let pages: Vec<u32> = doc.get_pages().keys().copied().collect();
|
||||
let mut text = String::new();
|
||||
let mut chars = 0usize;
|
||||
let mut cut = pages.len() > MAX_PDF_PAGES;
|
||||
if !doc.is_encrypted() {
|
||||
for page in pages.iter().take(MAX_PDF_PAGES) {
|
||||
// A page that can't be read is skipped; the others still give their text.
|
||||
let chunks = doc.extract_text_chunks_with_limit(&[*page], MAX_PDF_PAGE_BYTES);
|
||||
let page_text: String = chunks.into_iter().filter_map(Result::ok).collect();
|
||||
let page_text = page_text.trim();
|
||||
if page_text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !text.is_empty() {
|
||||
text.push_str("\n\n");
|
||||
}
|
||||
let room = MAX_PDF_CHARS.saturating_sub(chars);
|
||||
let n = page_text.chars().count();
|
||||
if n > room {
|
||||
text.extend(page_text.chars().take(room));
|
||||
cut = true;
|
||||
break;
|
||||
}
|
||||
text.push_str(page_text);
|
||||
chars += n;
|
||||
}
|
||||
}
|
||||
Some(PdfText { pages: pages.len(), text, cut })
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn pdf_text(_bytes: &[u8]) -> Option<PdfText> {
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Attachment {
|
||||
/// Its own on the message, never reused (set when it is added there; 0 until then): the
|
||||
@@ -282,9 +222,7 @@ pub fn read(path: &Path) -> Result<Attachment, String> {
|
||||
return Ok(Attachment { id: 0, name, content: Content::Reference { path, size: meta.len(), mime: "application/pdf" }, thumb: None });
|
||||
}
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(&name, e))?;
|
||||
// Read here, off the window thread: for an agent that takes no documents.
|
||||
let text = pdf_text(&bytes).map(Arc::new);
|
||||
Ok(Attachment { id: 0, name, content: Content::Pdf { bytes: Arc::new(bytes), path, text }, thumb: None })
|
||||
Ok(Attachment { id: 0, name, content: Content::Pdf { bytes: Arc::new(bytes), path }, thumb: None })
|
||||
}
|
||||
Kind::Other if matches!(path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref(), Some("heic" | "heif")) => {
|
||||
Err(refused("ui.agent_chat_file_heic", &name, &[]))
|
||||
@@ -396,24 +334,6 @@ fn thumbnail(img: &image::DynamicImage) -> Option<Arc<egui::ColorImage>> {
|
||||
Some(Arc::new(egui::ColorImage::from_rgba_unmultiplied(size, t.as_raw())))
|
||||
}
|
||||
|
||||
/// Add a file's text to the message under `head`, within the `left` characters shared by the
|
||||
/// files: whole when it fits; cut, saying how much is missing, when it doesn't; named only when
|
||||
/// nothing is left.
|
||||
fn fit(out: &mut String, left: &mut usize, head: &str, text: &str) {
|
||||
let t = text.trim_end();
|
||||
let len = t.chars().count();
|
||||
if *left == 0 {
|
||||
*out += &format!("\n\n{head}: left out, the message is at its size limit.");
|
||||
} else if len <= *left {
|
||||
*out += &format!("\n\n{head}:\n```\n{t}\n```");
|
||||
*left -= len;
|
||||
} else {
|
||||
let kept: String = t.chars().take(*left).collect();
|
||||
*out += &format!("\n\n{head} (cut: {} more characters left out):\n```\n{kept}\n```", len - *left);
|
||||
*left = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// What the message says about its files, after the person's words: the text files' text, and the
|
||||
/// pictures and imported parts by name (the agent sees the pictures themselves as images).
|
||||
/// `pictures_sent` and `documents_sent`: whether this agent takes pictures and PDFs. The text files share `budget` characters
|
||||
@@ -433,8 +353,21 @@ pub fn note(files: &[Attachment], pictures_sent: bool, documents_sent: bool, bud
|
||||
let mut left = budget;
|
||||
for f in files {
|
||||
match &f.content {
|
||||
Content::Text(t) => fit(&mut out, &mut left, &format!("Attached file `{}`", f.name), t),
|
||||
Content::Pdf { path, bytes, .. } if documents_sent => {
|
||||
Content::Text(t) => {
|
||||
let t = t.trim_end();
|
||||
let len = t.chars().count();
|
||||
if left == 0 {
|
||||
out += &format!("\n\nAttached file `{}`: left out, the message is at its size limit.", f.name);
|
||||
} else if len <= left {
|
||||
out += &format!("\n\nAttached file `{}`:\n```\n{t}\n```", f.name);
|
||||
left -= len;
|
||||
} else {
|
||||
let kept: String = t.chars().take(left).collect();
|
||||
out += &format!("\n\nAttached file `{}` (cut: {} more characters left out):\n```\n{kept}\n```", f.name, len - left);
|
||||
left = 0;
|
||||
}
|
||||
}
|
||||
Content::Pdf { path, bytes } if documents_sent => {
|
||||
out += &format!(
|
||||
"\n\nAttached PDF `{}` ({}, at {}): sent with this message as a document.",
|
||||
f.name,
|
||||
@@ -442,29 +375,14 @@ pub fn note(files: &[Attachment], pictures_sent: bool, documents_sent: bool, bud
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
// An agent that takes no documents gets the text, or is pointed to the file.
|
||||
Content::Pdf { path, bytes, text } => {
|
||||
let size = size_text(bytes.len() as u64);
|
||||
match text.as_deref().filter(|t| !t.text.trim().is_empty()) {
|
||||
Some(t) => {
|
||||
let cut = if t.cut { "; cut, the rest is in the file" } else { "" };
|
||||
let head = format!(
|
||||
"Attached PDF `{}` ({} pages, {size}, at {}): its text only, layout, pictures and fonts are dropped{cut}",
|
||||
f.name,
|
||||
t.pages,
|
||||
path.display()
|
||||
);
|
||||
fit(&mut out, &mut left, &head, &t.text);
|
||||
}
|
||||
None => {
|
||||
let why = if text.is_some() { "no text could be taken from it (scanned or protected)" } else { "its text could not be read" };
|
||||
out += &format!(
|
||||
"\n\nAttached file `{}` (PDF, {size}, at {}): {why}; read it with your own tools, it is not in this message.",
|
||||
f.name,
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
// An agent that takes no documents is pointed to the file, as for any other file.
|
||||
Content::Pdf { path, bytes } => {
|
||||
out += &format!(
|
||||
"\n\nAttached file `{}` (PDF, {}, at {}): read it with your own tools; it is not in this message.",
|
||||
f.name,
|
||||
size_text(bytes.len() as u64),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Content::Reference { path, size, mime } => {
|
||||
out += &format!(
|
||||
@@ -487,7 +405,7 @@ pub fn parts(files: &[Attachment], pictures: bool, documents: bool) -> Vec<Promp
|
||||
.iter()
|
||||
.filter_map(|f| match &f.content {
|
||||
Content::Picture { mime, bytes, .. } if pictures => Some(PromptPart::Image { mime: (*mime).to_string(), base64: backend::base64(bytes) }),
|
||||
Content::Pdf { bytes, path, .. } if documents => Some(PromptPart::Document {
|
||||
Content::Pdf { bytes, path } if documents => Some(PromptPart::Document {
|
||||
name: f.name.clone(),
|
||||
uri: file_uri(path),
|
||||
mime: "application/pdf".to_string(),
|
||||
@@ -758,107 +676,4 @@ mod tests {
|
||||
assert_eq!(size_text(1536), "2 KB");
|
||||
assert_eq!(size_text(5 * 1024 * 1024 + 512 * 1024), "5.5 MB");
|
||||
}
|
||||
|
||||
/// A PDF with one line of text on each page.
|
||||
fn sample_pdf(pages: &[&str]) -> Vec<u8> {
|
||||
use lopdf::content::{Content, Operation};
|
||||
use lopdf::{Document, Object, Stream, dictionary};
|
||||
let mut doc = Document::with_version("1.5");
|
||||
let pages_id = doc.new_object_id();
|
||||
let font = doc.add_object(dictionary! {"Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica"});
|
||||
let resources = doc.add_object(dictionary! {"Font" => dictionary! {"F1" => font}});
|
||||
let mut kids = Vec::new();
|
||||
for line in pages {
|
||||
let mut ops = vec![
|
||||
Operation::new("BT", vec![]),
|
||||
Operation::new("Tf", vec!["F1".into(), 12.into()]),
|
||||
Operation::new("Td", vec![72.into(), 700.into()]),
|
||||
];
|
||||
if !line.is_empty() {
|
||||
ops.push(Operation::new("Tj", vec![Object::string_literal(*line)]));
|
||||
}
|
||||
ops.push(Operation::new("ET", vec![]));
|
||||
let content = doc.add_object(Stream::new(dictionary! {}, Content { operations: ops }.encode().unwrap()));
|
||||
kids.push(Object::Reference(doc.add_object(dictionary! {"Type" => "Page", "Parent" => pages_id, "Contents" => content})));
|
||||
}
|
||||
let tree = dictionary! {
|
||||
"Type" => "Pages", "Kids" => kids, "Count" => pages.len() as i64, "Resources" => resources,
|
||||
"MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
|
||||
};
|
||||
doc.objects.insert(pages_id, Object::Dictionary(tree));
|
||||
let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages_id});
|
||||
doc.trailer.set("Root", catalog);
|
||||
let mut out = Vec::new();
|
||||
doc.save_to(&mut out).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// An agent that takes no documents gets the PDF's text, with its page count and a note that
|
||||
/// the layout was dropped; one that takes documents gets the PDF and not the text.
|
||||
#[test]
|
||||
fn a_pdf_gives_its_text_to_agents_without_documents() {
|
||||
let d = dir("pdftext");
|
||||
let path = d.join("brief.pdf");
|
||||
std::fs::write(&path, sample_pdf(&["Wall 2 mm", "Hole 5 mm"])).unwrap();
|
||||
let a = read(&path).unwrap();
|
||||
let Content::Pdf { text: Some(t), .. } = &a.content else { panic!("a PDF with its text: {:?}", a.content) };
|
||||
assert_eq!(t.pages, 2);
|
||||
assert!(!t.cut && t.text.contains("Wall 2 mm") && t.text.contains("Hole 5 mm"), "{:?}", t.text);
|
||||
assert!(t.text.find("Wall").unwrap() < t.text.find("Hole").unwrap(), "pages in order");
|
||||
let files = vec![a];
|
||||
let n = note(&files, false, false, usize::MAX);
|
||||
assert!(n.contains("Attached PDF `brief.pdf` (2 pages"), "{n}");
|
||||
assert!(n.contains("layout, pictures and fonts are dropped") && n.contains("Wall 2 mm"), "{n}");
|
||||
let short = note(&files, false, false, 5);
|
||||
assert!(short.contains("more characters left out"), "the text shares the message's budget: {short}");
|
||||
let with = note(&files, false, true, usize::MAX);
|
||||
assert!(with.contains("as a document") && !with.contains("Wall 2 mm"), "{with}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A PDF that is all pictures (a scan), or can't be read, says so and points to the file; the
|
||||
/// text kept is capped.
|
||||
#[test]
|
||||
fn pdfs_without_text_or_too_long_are_said_so() {
|
||||
let blank = pdf_text(&sample_pdf(&[""])).unwrap();
|
||||
assert_eq!((blank.pages, blank.text.as_str()), (1, ""));
|
||||
let a = Attachment {
|
||||
id: 0,
|
||||
name: "scan.pdf".into(),
|
||||
content: Content::Pdf { bytes: Arc::new(vec![1; 10]), path: "/a/scan.pdf".into(), text: Some(Arc::new(blank)) },
|
||||
thumb: None,
|
||||
};
|
||||
assert!(note(&[a], false, false, usize::MAX).contains("no text could be taken from it"));
|
||||
let broken = Attachment {
|
||||
id: 0,
|
||||
name: "x.pdf".into(),
|
||||
content: Content::Pdf { bytes: Arc::new(vec![1; 10]), path: "/a/x.pdf".into(), text: None },
|
||||
thumb: None,
|
||||
};
|
||||
assert!(note(&[broken], false, false, usize::MAX).contains("its text could not be read"));
|
||||
let long = "a".repeat(MAX_PDF_CHARS + 5000);
|
||||
let t = pdf_text(&sample_pdf(&[&long])).unwrap();
|
||||
assert!(t.cut && t.text.chars().count() == MAX_PDF_CHARS, "{} characters", t.text.chars().count());
|
||||
}
|
||||
|
||||
/// Hostile PDFs never panic: truncated and damaged copies of a real one are read or refused.
|
||||
#[test]
|
||||
fn damaged_pdfs_never_panic() {
|
||||
let good = sample_pdf(&["Wall 2 mm", "Hole 5 mm"]);
|
||||
assert!(pdf_text(&good).is_some());
|
||||
assert!(pdf_text(b"").is_none() && pdf_text(b"%PDF-1.7\n").is_none() && pdf_text(b"not a pdf").is_none());
|
||||
for cut in (0..good.len()).step_by(37) {
|
||||
let _ = pdf_text(&good[..cut]);
|
||||
}
|
||||
let mut seed = 0x9e37_79b9_u32;
|
||||
for _ in 0..200 {
|
||||
let mut bad = good.clone();
|
||||
for _ in 0..4 {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
let at = usize::try_from(seed >> 8).unwrap() % bad.len();
|
||||
bad[at] = (seed & 0xff) as u8;
|
||||
}
|
||||
let _ = pdf_text(&bad);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,8 +847,8 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let frame = Container::new(ui.ctx(), Surface::Viewport).edge_frame(egui::Color32::TRANSPARENT);
|
||||
let composer = Container::new(ui.ctx(), Surface::Viewport).edge(Id::new("sc_agent_chat_composer"), Edge::Bottom);
|
||||
composer.resizable(false).show_separator_line(false).frame(frame).show(ui, |ui| {
|
||||
ui_kit::assistant_composer_footer(ui, |ui| {
|
||||
ui_kit::assistant_composer(ui, focused, |ui| {
|
||||
let footer = ui_kit::assistant_composer_footer(ui, |ui| {
|
||||
let boxed = ui_kit::assistant_composer(ui, focused, |ui| {
|
||||
// What Pick context chose for this message, each item with an ×.
|
||||
if !app.agent_chat.picked_context.is_empty() {
|
||||
let tray = app.agent_chat.picked_context.clone();
|
||||
@@ -987,7 +987,9 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
},
|
||||
);
|
||||
});
|
||||
crate::scenario::publish_rect("agent_chat:composer_box", boxed.response.rect);
|
||||
});
|
||||
crate::scenario::publish_rect("agent_chat:composer_footer", footer.response.rect);
|
||||
});
|
||||
if let Some(at) = remove_picked_at {
|
||||
remove_picked(app, at);
|
||||
|
||||
@@ -2844,3 +2844,61 @@ fn a_screenshot_card_hides_the_path_until_details() {
|
||||
h.frames(3);
|
||||
assert!(h.widget("Result").is_some() && h.widget(path).is_some(), "Details holds the full path");
|
||||
}
|
||||
|
||||
/// The composer's input box fills its parent composer area on all four sides, in every layout:
|
||||
/// the only inset left is the text's own padding inside the box (no second margin around it).
|
||||
#[test]
|
||||
fn the_composer_box_fills_its_parent_in_every_layout() {
|
||||
use crate::scenario::rect_of;
|
||||
for (layout, popped) in [("floating", false), ("docked", false), ("floating", true), ("docked", true)] {
|
||||
let mut h = narrow_chat();
|
||||
h.call("ui.resize", json!({"width": 1600, "height": 1000}));
|
||||
h.call("ui.set", json!({"layout": layout}));
|
||||
h.app.preferences.agent_chat_popped = popped;
|
||||
h.app.agent_chat.state = Some(State::Ready);
|
||||
h.frames(4);
|
||||
let footer = rect_of("agent_chat:composer_footer").unwrap_or_else(|| panic!("{layout} popped={popped}: the composer area"));
|
||||
let boxed = rect_of("agent_chat:composer_box").expect("the input box");
|
||||
let (l, r, t, b) =
|
||||
(boxed.left() - footer.left(), footer.right() - boxed.right(), boxed.top() - footer.top(), footer.bottom() - boxed.bottom());
|
||||
for (edge, gap) in [("left", l), ("right", r), ("top", t), ("bottom", b)] {
|
||||
assert!(gap.abs() <= 1.0, "{layout} popped={popped}: box {edge} edge is {gap} from its parent ({boxed:?} in {footer:?})");
|
||||
}
|
||||
let input = rect_of("agent_chat:input").expect("the text");
|
||||
assert!(input.left() > boxed.left() + 2.0, "{layout} popped={popped}: the text keeps its own padding");
|
||||
let send = rect_of("agent_chat:send").expect("Send");
|
||||
assert!(boxed.contains_rect(send), "{layout} popped={popped}: Send stays inside the box");
|
||||
}
|
||||
}
|
||||
|
||||
/// The composer hint (idle and busy, in every language) stays on one row, even at the narrowest
|
||||
/// panel, and is cut with an ellipsis instead of wrapping.
|
||||
#[test]
|
||||
fn the_composer_hint_stays_on_one_row_at_the_narrowest_width() {
|
||||
let mut h = narrow_chat();
|
||||
h.app.agent_chat.state = Some(State::Ready);
|
||||
h.frames(4);
|
||||
let width = crate::scenario::rect_of("agent_chat:input").expect("the text box").width();
|
||||
let ctx = crate::fonts::test_context();
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/locales");
|
||||
let mut checked = 0;
|
||||
for entry in std::fs::read_dir(dir).unwrap().filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|x| x != "json") {
|
||||
continue;
|
||||
}
|
||||
let language = path.file_stem().unwrap().to_string_lossy().into_owned();
|
||||
let _scope = crate::i18n::scope(&language);
|
||||
for key in ["ui.agent_chat_placeholder", "ui.agent_chat_placeholder_busy"] {
|
||||
let hint = crate::i18n::tr(key).to_owned();
|
||||
let mut rows = 0;
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
rows = crate::ui_kit::assistant_hint_galley(ui, &hint, width - 2.0 * crate::theme::Tokens::get().form_inset).rows.len();
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(rows, 1, "{language} {key} at {width} wide: {hint}");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked >= 20, "every language was checked");
|
||||
}
|
||||
|
||||
@@ -3054,6 +3054,7 @@ pub(crate) fn corner_confirms(d: &Dialog) -> bool {
|
||||
/// Sketch Palette keeps the right there, and the dialog takes the left so both stay on screen.
|
||||
/// With Dialogs › In the left column it holds the left column instead (`dialog_column`).
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let escape_routed_here = !app.preferences.viewport_analysis_status || app.escape_owner.is_none_or(|owner| owner == crate::EscapeOwner::Dialog);
|
||||
// A click in the view's corner is for the dialog open this frame, or for none.
|
||||
let corner = app.viewport.corner.take();
|
||||
app.viewport.corner_ok = false;
|
||||
@@ -3248,7 +3249,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
apply_again = false;
|
||||
}
|
||||
crate::ui_kit::Action::Cancel => {
|
||||
if !app.esc_handled || !ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
if (!app.esc_handled && escape_routed_here) || !ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
cancel = true;
|
||||
}
|
||||
}
|
||||
@@ -3321,7 +3322,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !preset_prompt && !report_active && enter_applies && app.applying.is_none() && (enter || canvas_enter || (enter_free && nothing_focused)) {
|
||||
ok = true;
|
||||
}
|
||||
if !preset_prompt && !report_active && esc && !app.esc_handled {
|
||||
if !preset_prompt && !report_active && esc && !app.esc_handled && escape_routed_here {
|
||||
app.esc_handled = true;
|
||||
cancel = true;
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ fn paint_icon(ui: &egui::Ui, rect: egui::Rect, tex: Option<egui::TextureId>, nam
|
||||
egui::Align2::CENTER_CENTER,
|
||||
letter,
|
||||
egui::FontId::proportional(crate::theme::Tokens::get().font_small),
|
||||
t.text,
|
||||
crate::theme::readable(&[t.chip], crate::theme::TEXT_CONTRAST, &[t.text, t.on_accent]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +299,7 @@ mod view_confirm;
|
||||
pub mod view_pick;
|
||||
pub mod viewport;
|
||||
mod viewport_layout;
|
||||
mod viewport_status;
|
||||
mod viewport_status_fixture;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -452,7 +453,16 @@ pub struct PreSketchComponent {
|
||||
pub opened_in: u64,
|
||||
}
|
||||
|
||||
/// Which late UI handler has first refusal of this frame's Escape. Reserving input
|
||||
/// is distinct from having performed cancellation (`esc_handled`).
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) enum EscapeOwner {
|
||||
Dialog,
|
||||
Overlay,
|
||||
}
|
||||
|
||||
pub struct SolveApp {
|
||||
pub(crate) viewport_status: ui_kit::StatusPresentation,
|
||||
pub(crate) viewport_status_fixture: viewport_status_fixture::Fixture,
|
||||
/// Compact browser embed: retain model panels and joint controls without application chrome.
|
||||
pub embedded: bool,
|
||||
@@ -574,6 +584,7 @@ pub struct SolveApp {
|
||||
pub foreign_copy: Option<std::sync::Arc<solidcraft_engine::ForeignCopy>>,
|
||||
/// Esc was already acted on this frame (by the shortcuts), so later handlers skip it.
|
||||
pub esc_handled: bool,
|
||||
pub(crate) escape_owner: Option<EscapeOwner>,
|
||||
/// When Esc was last pressed (input time): a second press soon after finishes the sketch.
|
||||
pub last_escape: Option<f64>,
|
||||
pub status: Option<(String, f64, bool)>,
|
||||
@@ -709,6 +720,7 @@ impl SolveApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
solidcraft_engine::slicer_files::clean_old();
|
||||
let mut app = SolveApp {
|
||||
viewport_status: Default::default(),
|
||||
viewport_status_fixture: Default::default(),
|
||||
session,
|
||||
ui: UiState::default(),
|
||||
@@ -772,6 +784,7 @@ impl SolveApp {
|
||||
own_clipboard_stamp: 0,
|
||||
foreign_copy: None,
|
||||
esc_handled: false,
|
||||
escape_owner: None,
|
||||
last_escape: None,
|
||||
status: None,
|
||||
status_undo: false,
|
||||
@@ -2475,7 +2488,15 @@ impl SolveApp {
|
||||
}
|
||||
open_meshing::tick(self, ctx);
|
||||
open_meshing::refresh(self, ctx.input(|i| i.time));
|
||||
self.esc_handled = false;
|
||||
// Routing survives a layout re-pass, but never the next input frame. The legacy
|
||||
// policy retains its original per-pass cancellation flag behavior.
|
||||
let first_pass = ctx.current_pass_index() == 0;
|
||||
if first_pass {
|
||||
self.escape_owner = None;
|
||||
}
|
||||
if first_pass || !self.preferences.viewport_analysis_status {
|
||||
self.esc_handled = false;
|
||||
}
|
||||
prefs::apply(self, ctx);
|
||||
// The view's own dark or light only changes tokens, not egui's visuals: no restyle.
|
||||
theme::set_viewport_dark(self.preferences.viewport_theme.dark());
|
||||
|
||||
@@ -851,17 +851,7 @@ pub(crate) fn device(app: &mut SolveApp, rect: Rect, caps: NavCaps) {
|
||||
}
|
||||
});
|
||||
if let Some(queued) = app.viewport.device_motion.take() {
|
||||
device_camera(
|
||||
&mut app.cam,
|
||||
queued.motion,
|
||||
queued.seconds,
|
||||
height,
|
||||
turn,
|
||||
app.preferences.zoom_reverse,
|
||||
app.preferences.free_orbit,
|
||||
anchor,
|
||||
pivot,
|
||||
);
|
||||
device_camera(&mut app.cam, queued.motion, queued.seconds, height, turn, app.preferences.free_orbit, anchor, pivot);
|
||||
}
|
||||
// A device app's motion (the navigation bridge), on the same terms.
|
||||
if let Some(queued) = app.viewport.bridge_motion.take() {
|
||||
@@ -920,13 +910,14 @@ pub(crate) fn bridge_camera(
|
||||
/// the frame rate. With `turn` off the view keeps its orientation. `anchor` is the model point the
|
||||
/// view holds while it zooms, as the wheel's cursor point (none zooms through the model, #7435).
|
||||
/// `pivot` is the point the view turns around and keeps still on screen (none: the view's centre).
|
||||
/// Reverse zoom direction is a wheel setting and leaves the cap alone: the cap's own direction is
|
||||
/// its per-axis invert, already in `motion` (#8993).
|
||||
pub(crate) fn device_camera(
|
||||
cam: &mut Camera,
|
||||
motion: Motion,
|
||||
seconds: f64,
|
||||
height: f64,
|
||||
turn: bool,
|
||||
reverse_zoom: bool,
|
||||
free_orbit: bool,
|
||||
anchor: Option<Vec3>,
|
||||
pivot: Option<Vec3>,
|
||||
@@ -939,8 +930,7 @@ pub(crate) fn device_camera(
|
||||
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
|
||||
// profile can swap the physical forward/back and up/down axes before they reach here.
|
||||
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
|
||||
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
|
||||
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), anchor);
|
||||
cam.zoom_at((-y * 1.5 * seconds).exp(), anchor);
|
||||
if turn {
|
||||
// These are logical 3DxWare coordinates after the profile mapping: Rx is Tilt, Ry is
|
||||
// Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected navigation
|
||||
@@ -1710,7 +1700,7 @@ mod tests {
|
||||
}
|
||||
// Other presets keep the existing upright camera behavior.
|
||||
let mut cam = Camera { yaw: 0.0, pitch: 1.5, ..Default::default() };
|
||||
device_camera(&mut cam, Motion { translation: [0.0; 3], rotation: [1.0, 0.0, 0.0] }, 0.1, 600.0, true, false, false, None, None);
|
||||
device_camera(&mut cam, Motion { translation: [0.0; 3], rotation: [1.0, 0.0, 0.0] }, 0.1, 600.0, true, false, None, None);
|
||||
assert!(cam.pitch < std::f64::consts::FRAC_PI_2 && cam.pitch > 1.5);
|
||||
}
|
||||
|
||||
|
||||
@@ -1062,6 +1062,7 @@ preference_settings! {
|
||||
ribbon_labels: bool = true, PAGE_INTERFACE;
|
||||
/// Show engine-derived persistent selection in the ribbon; off retains neutral buttons.
|
||||
persistent_ribbon_pressed: bool = true, PAGE_INTERFACE => check_id("prefs.persistent_ribbon_pressed");
|
||||
viewport_analysis_status: bool = true, PAGE_INTERFACE => check_id("prefs.viewport_analysis_status");
|
||||
/// Keep the SKETCH tab in the ribbon, right after MODEL, while no sketch is open; its drawing
|
||||
/// tools then start a sketch first (#7904).
|
||||
sketch_tab_always: bool = false, PAGE_INTERFACE => check_id("prefs.sketch_tab_always");
|
||||
@@ -1077,6 +1078,12 @@ preference_settings! {
|
||||
toast_place: crate::viewport::ToastPlace = Default::default(), PAGE_INTERFACE;
|
||||
/// Toolbar tabs hidden by the person. An empty list keeps every existing tab visible.
|
||||
hidden_toolbar_tabs: Vec<String> = Vec::new(), PAGE_INTERFACE;
|
||||
/// Show the ribbon's panel drop-downs as a grid of large icons, their names in the tooltips,
|
||||
/// instead of a list of small icons with names (#4739).
|
||||
ribbon_icon_grid: bool = false, PAGE_INTERFACE;
|
||||
/// Leave the commands the ribbon shows as buttons (pinned ones) out of its panel drop-downs,
|
||||
/// which then list only the rest (#4739).
|
||||
ribbon_menus_skip_pinned: bool = false, PAGE_INTERFACE => check_tr("ui.ribbon_menus_skip_pinned");
|
||||
/// Interface scale (1 = 100 %).
|
||||
ui_scale: f32 = 1.0, PAGE_INTERFACE, accept(0.5..=2.5);
|
||||
/// Interface font: an installed font family (or a font file), or empty for the built-in one
|
||||
@@ -2722,7 +2729,7 @@ fn space_mouse_rows(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
// The calibrated device moving as it does, with the test model beside it, then its dead zones.
|
||||
let (device, raw) = (app.space_mouse.device.clone(), app.space_mouse.raw.into());
|
||||
let (boat, motion, reverse) = (&mut app.space_mouse.boat, app.space_mouse.motion, (app.preferences.zoom_reverse, app.preferences.free_orbit));
|
||||
let (boat, motion, free_orbit) = (&mut app.space_mouse.boat, app.space_mouse.motion, app.preferences.free_orbit);
|
||||
if let Some(calibration) = device.as_ref().and_then(|device| app.preferences.space_mouse_calibrations.get_mut(device)) {
|
||||
ui_kit::divider(ui, "space_mouse_picture");
|
||||
// Under each circle's axes, what the view does with them: the rows' own labels.
|
||||
@@ -2732,11 +2739,11 @@ fn space_mouse_rows(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::space_mouse_calibration::dead_zone_pads(ui, calibration, raw, false, &stories);
|
||||
ui_kit::text(ui, &help, ui_kit::Text::Hint("space_mouse_circles_help"));
|
||||
};
|
||||
picture_and_boat(ui, boat, motion, reverse, true, |ui| crate::space_mouse_model::live(ui, calibration, raw), pads);
|
||||
picture_and_boat(ui, boat, motion, free_orbit, true, |ui| crate::space_mouse_model::live(ui, calibration, raw), pads);
|
||||
} else if device.is_some() {
|
||||
// Not calibrated: the test model alone.
|
||||
ui_kit::divider(ui, "space_mouse_picture");
|
||||
picture_and_boat(ui, boat, motion, reverse, false, |_| {}, |_| {});
|
||||
picture_and_boat(ui, boat, motion, free_orbit, false, |_| {}, |_| {});
|
||||
}
|
||||
ui_kit::settings_form(ui, FORM_LABEL_WIDTH, |ui| {
|
||||
hint_under_controls(ui, crate::i18n::tr_label("Move and rotate the view together; sketches use pan and zoom only."), "space_mouse");
|
||||
@@ -2810,7 +2817,7 @@ fn picture_and_boat(
|
||||
ui: &mut egui::Ui,
|
||||
boat: &mut Option<crate::test_boat::Boat>,
|
||||
motion: Option<crate::space_mouse::DeviceMotion>,
|
||||
(reverse_zoom, free_orbit): (bool, bool),
|
||||
free_orbit: bool,
|
||||
has_picture: bool,
|
||||
picture: impl FnOnce(&mut egui::Ui),
|
||||
pads: impl FnOnce(&mut egui::Ui),
|
||||
@@ -2825,7 +2832,7 @@ fn picture_and_boat(
|
||||
let width = if beside { (room - PICTURE_WIDTH - gap).min(BOAT_MAX) } else { room.min(BOAT_MAX) };
|
||||
let mut boat_and_reset = |ui: &mut egui::Ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::test_boat::show(ui, boat, motion, reverse_zoom, free_orbit, egui::vec2(width.max(0.0), BOAT_HEIGHT));
|
||||
crate::test_boat::show(ui, boat, motion, free_orbit, egui::vec2(width.max(0.0), BOAT_HEIGHT));
|
||||
if ui_kit::button(ui, crate::i18n::tr("ui.3d_mouse_reset_view"), ui_kit::Button::Normal).clicked() {
|
||||
crate::test_boat::reset(boat);
|
||||
}
|
||||
@@ -4957,10 +4964,13 @@ mod tests {
|
||||
("layout", json!("compact"), PAGE_INTERFACE),
|
||||
("ribbon_labels", json!(false), PAGE_INTERFACE),
|
||||
("persistent_ribbon_pressed", json!(false), PAGE_INTERFACE),
|
||||
("viewport_analysis_status", json!(false), PAGE_INTERFACE),
|
||||
("sketch_tab_always", json!(true), PAGE_INTERFACE),
|
||||
("ribbon_reveal", json!(true), PAGE_INTERFACE),
|
||||
("dialog_buttons", json!("top"), PAGE_INTERFACE),
|
||||
("toast_place", json!("centre"), PAGE_INTERFACE),
|
||||
("ribbon_icon_grid", json!(true), PAGE_INTERFACE),
|
||||
("ribbon_menus_skip_pinned", json!(true), PAGE_INTERFACE),
|
||||
("timeline_left", json!(true), PAGE_INTERFACE),
|
||||
("timeline_under_browser", json!(true), PAGE_INTERFACE),
|
||||
("history_sketches", json!("under_feature"), PAGE_INTERFACE),
|
||||
@@ -6336,6 +6346,59 @@ mod tests {
|
||||
assert_eq!(h.app.session.doc.to_json(), design);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_analysis_status_preference_checkbox_control_persists_cancels_and_resets() {
|
||||
assert!(Prefs::default().viewport_analysis_status);
|
||||
assert!(lenient(&json!({"theme":"light"})).viewport_analysis_status);
|
||||
assert!(lenient(&json!({"viewport_analysis_status":"no"})).viewport_analysis_status);
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let design = h.app.session.doc.to_json();
|
||||
let revision = h.app.session.revision;
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
super::reveal_preference(&mut h, "prefs:viewport_analysis_status");
|
||||
let checkbox = crate::scenario::handle_at("prefs:viewport_analysis_status").unwrap();
|
||||
h.call("ui.click", json!({"x":checkbox.x,"y":checkbox.y}));
|
||||
assert!(!h.app.preferences.viewport_analysis_status);
|
||||
assert_eq!(h.call("ui.prefs", json!({}))["result"]["viewport_analysis_status"], false);
|
||||
let mut restored = SolveApp::new(Default::default(), Default::default());
|
||||
restored.load_prefs(&h.app.prefs());
|
||||
assert!(!restored.preferences.viewport_analysis_status);
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert!(h.app.preferences.viewport_analysis_status);
|
||||
h.call("ui.prefs", json!({"open":"Interface","viewport_analysis_status":false,"button":"apply"}));
|
||||
h.call("ui.prefs", json!({"button":"defaults"}));
|
||||
assert!(h.app.preferences.viewport_analysis_status);
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
assert!(!h.app.preferences.viewport_analysis_status);
|
||||
for malformed in [json!("false"), json!(null), json!(0), json!([])] {
|
||||
assert!(super::set(&mut h.app, &json!({"viewport_analysis_status":malformed})).is_err());
|
||||
assert!(!h.app.preferences.viewport_analysis_status);
|
||||
}
|
||||
assert_eq!(h.app.session.revision, revision);
|
||||
assert_eq!(h.app.session.doc.to_json(), design);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_analysis_status_is_independent_of_session_policy_in_background_documents() {
|
||||
let mut app = SolveApp::new(Default::default(), Default::default());
|
||||
super::set(&mut app, &json!({"single_surface_analysis":false,"persistent_ribbon_pressed":false})).unwrap();
|
||||
app.run("inspect.zebra", json!({})).unwrap();
|
||||
let mut other = solidcraft_engine::Session::default();
|
||||
other.execute("inspect.draft", &json!({})).unwrap();
|
||||
crate::documents::open_tab(&mut app, other).unwrap();
|
||||
let before: Vec<_> =
|
||||
std::iter::once(&app.session).chain(app.docs.background()).map(|s| (s.status_items(), s.revision, s.undo.len())).collect();
|
||||
super::control(&mut app, &json!({"open":"Interface","viewport_analysis_status":false})).unwrap();
|
||||
assert!(!app.preferences.viewport_analysis_status);
|
||||
assert!(!app.preferences.persistent_ribbon_pressed);
|
||||
assert!(std::iter::once(&app.session).chain(app.docs.background()).all(|s| !s.single_surface_analysis()));
|
||||
super::control(&mut app, &json!({"button":"cancel"})).unwrap();
|
||||
assert!(app.preferences.viewport_analysis_status);
|
||||
let after: Vec<_> =
|
||||
std::iter::once(&app.session).chain(app.docs.background()).map(|s| (s.status_items(), s.revision, s.undo.len())).collect();
|
||||
assert_eq!(before, after, "a presentation preference never changes effects in any document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_surface_analysis_preference_checkbox_persists_cancels_and_resets() {
|
||||
assert!(Prefs::default().single_surface_analysis);
|
||||
|
||||
@@ -98,6 +98,10 @@ pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let check = setting::persistent_ribbon_pressed(app, ui).on_hover_text(app.tr("prefs.persistent_ribbon_pressed_help"));
|
||||
crate::scenario::publish_target("prefs:persistent_ribbon_pressed", check.rect);
|
||||
});
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::viewport_analysis_status(app, ui).on_hover_text(app.tr("prefs.viewport_analysis_status_help"));
|
||||
crate::scenario::publish_target("prefs:viewport_analysis_status", check.rect);
|
||||
});
|
||||
// The SKETCH tab outside a sketch too (#7904).
|
||||
ui_kit::row(ui, "", |ui| {
|
||||
let check = setting::sketch_tab_always(app, ui).on_hover_text(app.tr("prefs.sketch_tab_always_note"));
|
||||
@@ -141,6 +145,14 @@ pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
crate::scenario::publish_handle(&format!("prefs:toast_place:{key}"), at);
|
||||
}
|
||||
}
|
||||
// The ribbon's panel drop-downs: a list, or a grid of large icons (#4739).
|
||||
ui_kit::row(ui, app.tr("prefs.menus"), |ui| {
|
||||
let mut selected = usize::from(app.preferences.ribbon_icon_grid);
|
||||
if ui_kit::segmented(ui, &mut selected, &[app.tr("prefs.menu_list"), app.tr("prefs.menu_grid")], Default::default()) {
|
||||
app.preferences.ribbon_icon_grid = selected == 1;
|
||||
}
|
||||
});
|
||||
setting::ribbon_menus_skip_pinned(app, ui);
|
||||
ui_kit::row(ui, app.tr("prefs.history"), |ui| {
|
||||
let mut selected = if app.preferences.history_in_browser {
|
||||
3
|
||||
|
||||
@@ -36,7 +36,7 @@ use solidcraft_engine::sketch::PatternAlignment;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind};
|
||||
use crate::selection::{AXES, CURVES, SKETCH_POINTS, SelInput};
|
||||
use crate::selection::{AXES, CURVES, PROFILES, SKETCH_POINTS, SelInput};
|
||||
use crate::tool_modifiers::{Audit, Scope, ToolModifiers};
|
||||
use crate::ui_kit;
|
||||
use crate::viewport::{Hit, Proj};
|
||||
@@ -185,21 +185,25 @@ impl Mod {
|
||||
|
||||
fn inputs(k: &Mod) -> Vec<SelInput> {
|
||||
match k {
|
||||
Mod::Scale { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Base point", SKETCH_POINTS, false)],
|
||||
Mod::Scale { .. } => {
|
||||
vec![SelInput::new("Entities", CURVES | SKETCH_POINTS | PROFILES, true), SelInput::new("Base point", SKETCH_POINTS, false)]
|
||||
}
|
||||
Mod::Offset { .. } => vec![SelInput::new("Curves", CURVES, true)],
|
||||
// AXES is the mask that takes a straight sketch curve (`selection::SelInput::accepts`).
|
||||
Mod::Mirror => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Mirror line", AXES, false)],
|
||||
Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)],
|
||||
Mod::Transform { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true)],
|
||||
Mod::Mirror => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS | PROFILES, true), SelInput::new("Mirror line", AXES, false)],
|
||||
Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS | PROFILES, true), SelInput::new("Center", SKETCH_POINTS, false)],
|
||||
Mod::Transform { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS | PROFILES, true)],
|
||||
// The direction is optional: `sketch.pattern.rectangular` documents sketch x as its
|
||||
// default, the way the engine reads it.
|
||||
Mod::PatternRect { .. } => vec![
|
||||
SelInput::new("Entities", CURVES | SKETCH_POINTS, true),
|
||||
SelInput::new("Entities", CURVES | SKETCH_POINTS | PROFILES, true),
|
||||
SelInput::new("Direction", AXES, false),
|
||||
SelInput::new("Direction 2", AXES, false),
|
||||
],
|
||||
// The centre is required: `sketch.pattern.circular` has no default for it.
|
||||
Mod::PatternCirc { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)],
|
||||
Mod::PatternCirc { .. } => {
|
||||
vec![SelInput::new("Entities", CURVES | SKETCH_POINTS | PROFILES, true), SelInput::new("Center", SKETCH_POINTS, false)]
|
||||
}
|
||||
// A fillet rounds any number of corners to one radius (`points`). Each chamfer command
|
||||
// cuts one corner: its point, or its two lines. Two lines name the corner between them
|
||||
// where more lines meet at its point (#1824), for both.
|
||||
@@ -308,7 +312,7 @@ pub(crate) fn selected_equation(app: &SolveApp) -> Option<String> {
|
||||
pub fn candidate(d: &Dialog, session: &solidcraft_engine::Session, hit: &Hit) -> Option<Sel> {
|
||||
let active = session.active_sketch?;
|
||||
match hit {
|
||||
Hit::SketchCurve { sketch, .. } | Hit::SketchPoint { sketch, .. } if *sketch != active => None,
|
||||
Hit::SketchCurve { sketch, .. } | Hit::SketchPoint { sketch, .. } | Hit::Profile { sketch, .. } if *sketch != active => None,
|
||||
Hit::SketchCurve { id, .. } if matches!(d.kind, Kind::SketchModify(Mod::Corner { .. })) => {
|
||||
let state = session.world_state();
|
||||
let sk = &state.sketch(active)?.sketch;
|
||||
@@ -395,6 +399,50 @@ pub fn double_click_pattern(app: &mut SolveApp, pos: Pos2) -> bool {
|
||||
id.is_some_and(|id| edit_pattern(app, &id))
|
||||
}
|
||||
|
||||
/// Double-click a sketch curve to add its whole connected chain to the shared Entities input of
|
||||
/// Mirror, Move/Copy, Scale and the two patterns, as a profile click adds a whole closed region
|
||||
/// (#8390). The single-curve pick of the double-click's two clicks cancels itself out (each click
|
||||
/// toggles the same curve), so the chain toggle lands on the state before the double-click.
|
||||
pub fn double_click_entities(app: &mut SolveApp) -> bool {
|
||||
let Some(Hit::SketchCurve { sketch, id, .. }) = app.viewport.hover.clone() else {
|
||||
return false;
|
||||
};
|
||||
if app.session.active_sketch != Some(sketch) {
|
||||
return false;
|
||||
}
|
||||
let Some(dialog) = app.dialog.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
// Only the commands whose first input is the shared Entities list take a chain here; Offset
|
||||
// has its own Chain option, and the corner tools take one corner at a time.
|
||||
let entities = matches!(
|
||||
&dialog.kind,
|
||||
Kind::SketchModify(
|
||||
Mod::Scale { .. } | Mod::Mirror | Mod::Move { .. } | Mod::Transform { .. } | Mod::PatternRect { .. } | Mod::PatternCirc { .. }
|
||||
)
|
||||
);
|
||||
if !entities || dialog.active != 0 {
|
||||
return false;
|
||||
}
|
||||
let Ok(v) = app.session.execute("sketch.chain", &json!({ "curve": id })) else {
|
||||
return false;
|
||||
};
|
||||
let items: Vec<Sel> = v["curves"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| c.as_str())
|
||||
.map(|id| Sel::SketchCurve { id: id.to_string(), sketch: None })
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if let Some(input) = dialog.inputs.get_mut(0) {
|
||||
input.toggle(items);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Place a circular pattern centre in empty sketch space without creating a document point.
|
||||
pub fn pick_position(d: &mut Dialog, session: &solidcraft_engine::Session, point: Vec3) -> bool {
|
||||
if !matches!(d.kind, Kind::SketchModify(Mod::PatternCirc { .. })) || d.active != 1 {
|
||||
@@ -668,15 +716,26 @@ pub(crate) fn point_id<'a>(session: &solidcraft_engine::Session, sel: &'a Sel) -
|
||||
}
|
||||
}
|
||||
|
||||
/// The curves and lone points picked in input `i`, as the engine's `entities` list.
|
||||
/// The curves and lone points picked in input `i`, as the engine's `entities` list. A profile
|
||||
/// pick expands to every curve of its boundary and its holes (#8390).
|
||||
fn entity_ids(session: &solidcraft_engine::Session, inputs: &[SelInput], i: usize) -> Vec<String> {
|
||||
items(inputs, i)
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
Sel::SketchCurve { id, .. } => Some(id.clone()),
|
||||
_ => point_id(session, x).map(str::to_string),
|
||||
})
|
||||
.collect()
|
||||
let mut out = Vec::new();
|
||||
for x in items(inputs, i) {
|
||||
match x {
|
||||
Sel::SketchCurve { id, .. } => out.push(id.clone()),
|
||||
Sel::Profile { sketch, index } => {
|
||||
if let Some(ids) = session.world_state().sketch(*sketch).and_then(|ss| ss.profiles.get(*index)).map(|p| p.curve_ids()) {
|
||||
out.extend(ids);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(id) = point_id(session, x) {
|
||||
out.push(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Sketch curve ids picked in input `i`.
|
||||
@@ -889,9 +948,8 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
|
||||
Some(_) => return Err("The mirror line must be a straight curve of this sketch or an origin axis in its plane".into()),
|
||||
None => return Err("Select the mirror line".into()),
|
||||
};
|
||||
if line.as_str().is_some_and(|line| entities.iter().any(|entity| entity == line)) {
|
||||
return Err("The mirror line cannot also be mirrored".into());
|
||||
}
|
||||
// The mirror line may itself be among the entities (a chain or profile the user
|
||||
// picked in one action): the command leaves the line in place and mirrors the rest.
|
||||
("sketch.mirror", json!({"entities": entities, "line": line}))
|
||||
}
|
||||
Mod::Move { x, y, angle, copy } => {
|
||||
|
||||
@@ -232,12 +232,12 @@ fn a_half_filled_window_is_an_error_instead_of_a_command() {
|
||||
origin_axis[0].items = vec![Sel::SketchCurve { id: ids[1].clone(), sketch: None }];
|
||||
origin_axis[1].items = vec![Sel::Axis { name: "Z".into() }];
|
||||
assert!(commands(&h.app, &Mod::Mirror, &origin_axis, &serde_json::Map::new()).is_err());
|
||||
// The line itself must not be in the entities: the old click flow could not express that
|
||||
// either, and the engine silently dropped it.
|
||||
// The mirror line may itself be among the entities (a chain or profile picked in one action):
|
||||
// the command leaves the line in place and mirrors the rest.
|
||||
let mut line_in_both = mirror_inputs();
|
||||
line_in_both[0].items = vec![Sel::SketchCurve { id: ids[0].clone(), sketch: None }];
|
||||
line_in_both[1].items = vec![Sel::SketchCurve { id: ids[0].clone(), sketch: None }];
|
||||
assert!(commands(&h.app, &Mod::Mirror, &line_in_both, &serde_json::Map::new()).is_err());
|
||||
assert_eq!(commands(&h.app, &Mod::Mirror, &line_in_both, &serde_json::Map::new()).unwrap()[0].1, json!({"entities": [ids[0]], "line": ids[0]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -693,8 +693,8 @@ mod tests {
|
||||
|
||||
/// The camera math the page applies the motion with (`navigation::device`): `sketch` keeps
|
||||
/// the orientation, as editing a sketch does.
|
||||
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
|
||||
crate::navigation::device_camera(cam, motion, seconds, height, !sketch, reverse_zoom, false, None, None);
|
||||
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool) {
|
||||
crate::navigation::device_camera(cam, motion, seconds, height, !sketch, false, None, None);
|
||||
}
|
||||
|
||||
/// A window that has run a frame, with or without focus. A context that has never run one
|
||||
@@ -1174,14 +1174,14 @@ mod tests {
|
||||
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
|
||||
let mut cam = Camera::default();
|
||||
let before = cam;
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false);
|
||||
assert_ne!(cam.target, before.target);
|
||||
assert!(cam.distance < before.distance);
|
||||
assert_ne!(cam.yaw, before.yaw);
|
||||
assert_ne!(cam.pitch, before.pitch);
|
||||
assert_ne!(cam.roll, before.roll);
|
||||
let orientation = (cam.yaw, cam.pitch, cam.roll);
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true);
|
||||
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
|
||||
assert!(cam.is_valid());
|
||||
}
|
||||
@@ -1191,57 +1191,79 @@ mod tests {
|
||||
let before = Camera::default();
|
||||
|
||||
let mut slide = before;
|
||||
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false);
|
||||
assert_ne!(slide.target, before.target);
|
||||
assert_eq!(slide.distance, before.distance);
|
||||
|
||||
let mut push = before;
|
||||
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false);
|
||||
assert_eq!(push.target, before.target);
|
||||
assert!(push.distance < before.distance);
|
||||
|
||||
let mut lift = before;
|
||||
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false);
|
||||
assert_ne!(lift.target, before.target);
|
||||
assert_eq!(lift.distance, before.distance);
|
||||
|
||||
let mut tilt = before;
|
||||
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
|
||||
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false);
|
||||
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
|
||||
assert_ne!(tilt.pitch, before.pitch);
|
||||
|
||||
let mut roll = before;
|
||||
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false);
|
||||
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
|
||||
assert_ne!(roll.yaw, before.yaw);
|
||||
|
||||
let mut twist = before;
|
||||
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false);
|
||||
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(twist.roll, before.roll);
|
||||
}
|
||||
|
||||
/// Reverse zoom direction is a wheel and trackpad setting: it turns the wheel's zoom around
|
||||
/// and leaves the cap's, which has its own invert switch (#8993).
|
||||
#[test]
|
||||
fn reverse_zoom_setting_applies_to_spacemouse() {
|
||||
let before = Camera::default();
|
||||
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
|
||||
let mut normal = before;
|
||||
navigate(&mut normal, motion, 0.02, 600.0, false, false);
|
||||
let mut reversed = before;
|
||||
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
|
||||
assert!(normal.distance < before.distance);
|
||||
assert!(reversed.distance > before.distance);
|
||||
fn reverse_zoom_setting_flips_the_wheel_and_leaves_the_cap() {
|
||||
let wheel_zooms_in = |reverse: bool| {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.zoom_reverse = reverse;
|
||||
h.app.preferences.smooth_wheel_zoom = false;
|
||||
h.call("ui.move", serde_json::json!({"x":800,"y":500}));
|
||||
let before = h.app.cam.half_height();
|
||||
h.call("ui.scroll", serde_json::json!({"x":800,"y":500,"delta":120}));
|
||||
h.app.cam.half_height() < before
|
||||
};
|
||||
assert!(wheel_zooms_in(false));
|
||||
assert!(!wheel_zooms_in(true), "the wheel turns around");
|
||||
// The cap's zoom axis pushed forward with the wheel reversed or not, and with the cap's own
|
||||
// invert switch for that axis on or off.
|
||||
let cap_distance = |wheel_reversed: bool, cap_inverted: bool| {
|
||||
let mut app = app();
|
||||
app.preferences.zoom_reverse = wheel_reversed;
|
||||
app.preferences.space_mouse_reverse[1] = cap_inverted;
|
||||
let ctx = window(true);
|
||||
app.space_mouse.device = Some("hid 046d:c62b".into());
|
||||
let before = app.cam.distance;
|
||||
frame(&mut app, &ctx, DriverMapping::default(), Axes { translation: [0, 350, 0], rotation: [0; 3] }, 0.02);
|
||||
(before, app.cam.distance)
|
||||
};
|
||||
let (before, normal) = cap_distance(false, false);
|
||||
assert!(normal < before, "pushing the cap forward zooms in");
|
||||
assert_eq!(cap_distance(true, false).1, normal, "the cap zooms the same way with the wheel reversed");
|
||||
assert!(cap_distance(false, true).1 > before, "the cap's own invert switch turns it around");
|
||||
assert_eq!(cap_distance(true, true).1, cap_distance(false, true).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_mode_tilts_roll_and_twists_spin() {
|
||||
let before = Camera::default();
|
||||
let mut tilt = before;
|
||||
navigate(&mut tilt, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
navigate(&mut tilt, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false);
|
||||
assert_eq!((tilt.yaw, tilt.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(tilt.roll, before.roll);
|
||||
let mut twist = before;
|
||||
navigate(&mut twist, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
navigate(&mut twist, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false);
|
||||
assert_ne!(twist.yaw, before.yaw);
|
||||
assert_eq!((twist.pitch, twist.roll), (before.pitch, before.roll));
|
||||
// The physical tilt right (Ry−) rolls one way and the clockwise twist (Rz+) spins the
|
||||
|
||||
@@ -109,17 +109,8 @@ impl Boat {
|
||||
|
||||
/// `motion` for `seconds` on the boat's camera in a box `aspect` wide for its height of `height`
|
||||
/// points, with pan and zoom held so a sphere of `radius` stays inside it.
|
||||
pub(crate) fn step(
|
||||
cam: &mut Camera,
|
||||
motion: crate::space_mouse::Motion,
|
||||
seconds: f64,
|
||||
height: f64,
|
||||
radius: f64,
|
||||
aspect: f64,
|
||||
reverse_zoom: bool,
|
||||
free_orbit: bool,
|
||||
) {
|
||||
crate::navigation::device_camera(cam, motion, seconds, height, true, reverse_zoom, free_orbit, None, None);
|
||||
pub(crate) fn step(cam: &mut Camera, motion: crate::space_mouse::Motion, seconds: f64, height: f64, radius: f64, aspect: f64, free_orbit: bool) {
|
||||
crate::navigation::device_camera(cam, motion, seconds, height, true, free_orbit, None, None);
|
||||
cam.fov = FOV;
|
||||
// Zoom within limits: the nearest still shows the whole boat in the shorter side of the box.
|
||||
let shorter = aspect.min(1.0);
|
||||
@@ -171,14 +162,7 @@ pub(crate) fn project(model: &Model, cam: &Camera, rect: Rect) -> Frame {
|
||||
}
|
||||
|
||||
/// Draws the boat in a box of `size` and moves it by this frame's motion. `boat` is the page's state.
|
||||
pub(crate) fn show(
|
||||
ui: &mut egui::Ui,
|
||||
boat: &mut Option<Boat>,
|
||||
motion: Option<DeviceMotion>,
|
||||
reverse_zoom: bool,
|
||||
free_orbit: bool,
|
||||
size: egui::Vec2,
|
||||
) -> Option<Rect> {
|
||||
pub(crate) fn show(ui: &mut egui::Ui, boat: &mut Option<Boat>, motion: Option<DeviceMotion>, free_orbit: bool, size: egui::Vec2) -> Option<Rect> {
|
||||
let model = model()?;
|
||||
let t = Tokens::get();
|
||||
let now = ui.input(|i| i.time);
|
||||
@@ -188,7 +172,7 @@ pub(crate) fn show(
|
||||
state.seen = now;
|
||||
if let Some(motion) = motion {
|
||||
let aspect = f64::from(rect.width()) / f64::from(rect.height().max(1.0));
|
||||
step(&mut state.cam, motion.motion, motion.seconds, f64::from(rect.height()), model.radius, aspect, reverse_zoom, free_orbit);
|
||||
step(&mut state.cam, motion.motion, motion.seconds, f64::from(rect.height()), model.radius, aspect, free_orbit);
|
||||
}
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.rect(rect, t.radius_sm, t.dialog_body, egui::Stroke::new(1.0, t.dialog_rule), egui::StrokeKind::Inside);
|
||||
|
||||
@@ -51,7 +51,7 @@ fn test_boat_step_orbits_pans_zooms_rolls_and_holds_the_boat_in_its_box() {
|
||||
let aspect = 1.4;
|
||||
let run = |m: Motion, seconds: f64| {
|
||||
let mut cam = start;
|
||||
step(&mut cam, m, seconds, 150.0, radius, aspect, false, false);
|
||||
step(&mut cam, m, seconds, 150.0, radius, aspect, false);
|
||||
cam
|
||||
};
|
||||
// Nothing moves nothing.
|
||||
@@ -70,17 +70,17 @@ fn test_boat_step_orbits_pans_zooms_rolls_and_holds_the_boat_in_its_box() {
|
||||
// Held down for a long time, the limits hold: nearest, farthest, and the pan room.
|
||||
let mut cam = start;
|
||||
for _ in 0..400 {
|
||||
step(&mut cam, motion([1.0, 1.0, 1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false, false);
|
||||
step(&mut cam, motion([1.0, 1.0, 1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false);
|
||||
}
|
||||
assert!(cam.distance >= radius * NEAREST / 1.0 - 1e-6 && cam.target.len() <= cam.distance * (FOV / 2.0).tan() + 1e-6);
|
||||
let mut cam = start;
|
||||
for _ in 0..400 {
|
||||
step(&mut cam, motion([-1.0, -1.0, -1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false, false);
|
||||
step(&mut cam, motion([-1.0, -1.0, -1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false);
|
||||
}
|
||||
assert!(cam.distance <= radius * FARTHEST + 1e-6);
|
||||
// Hostile numbers leave a valid camera.
|
||||
let mut cam = start;
|
||||
step(&mut cam, motion([f64::NAN, f64::INFINITY, 1e300], [f64::NAN; 3]), 0.05, 150.0, radius, aspect, false, false);
|
||||
step(&mut cam, motion([f64::NAN, f64::INFINITY, 1e300], [f64::NAN; 3]), 0.05, 150.0, radius, aspect, false);
|
||||
assert!(cam.is_valid());
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ fn test_boat_draws_inside_its_box_at_every_size_and_pose() {
|
||||
// Through the extremes of what the 3D mouse can do.
|
||||
for (translation, rotation) in [([0.0; 3], [0.0; 3]), ([1.0, -1.0, 1.0], [1.0, 0.0, 1.0]), ([-1.0, 1.0, -1.0], [0.0, 1.0, -1.0])] {
|
||||
for _ in 0..200 {
|
||||
step(&mut cam, motion(translation, rotation), 0.05, f64::from(h), m.radius, aspect, false, false);
|
||||
step(&mut cam, motion(translation, rotation), 0.05, f64::from(h), m.radius, aspect, false);
|
||||
}
|
||||
let frame = project(m, &cam, rect.shrink(1.0));
|
||||
assert!(!frame.indices.is_empty(), "{w}x{h}: something is drawn");
|
||||
@@ -141,7 +141,7 @@ fn test_boat_far_faces_come_first_and_a_page_opened_again_starts_afresh() {
|
||||
let moved = Some(DeviceMotion { motion: motion([0.0; 3], [1.0, 0.0, 0.0]), seconds: 0.05 });
|
||||
let draw = |time: f64, motion: Option<DeviceMotion>, state: &mut Option<Boat>| {
|
||||
let mut output = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| {
|
||||
show(ui, state, motion, false, false, vec2(200.0, 150.0));
|
||||
show(ui, state, motion, false, vec2(200.0, 150.0));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
};
|
||||
|
||||
@@ -286,6 +286,28 @@ pub(crate) fn contrast_ratio(a: Color32, b: Color32) -> f64 {
|
||||
(a.max(b) + 0.05) / (a.min(b) + 0.05)
|
||||
}
|
||||
|
||||
/// WCAG floors: body text, and icons, bold labels and other large marks.
|
||||
pub(crate) const TEXT_CONTRAST: f64 = 4.5;
|
||||
pub(crate) const MARK_CONTRAST: f64 = 3.0;
|
||||
|
||||
/// The first of `inks` that reads at `min` on every one of `fills`, else whichever of them reads
|
||||
/// best on its worst fill. Themes older than a surface can pair a fill with an ink that vanishes
|
||||
/// on it (SolidCraft 95's navy accent on its navy selection); this keeps such a pair readable
|
||||
/// without changing any theme where the preferred ink already reads.
|
||||
pub(crate) fn readable(fills: &[Color32], min: f64, inks: &[Color32]) -> Color32 {
|
||||
let worst = |ink: Color32| fills.iter().map(|fill| contrast_ratio(ink, *fill)).fold(f64::INFINITY, f64::min);
|
||||
inks.iter()
|
||||
.copied()
|
||||
.find(|ink| worst(*ink) >= min)
|
||||
.or_else(|| inks.iter().copied().max_by(|a, b| worst(*a).total_cmp(&worst(*b))))
|
||||
.unwrap_or(Color32::BLACK)
|
||||
}
|
||||
|
||||
/// Is `fill` dark enough that light artwork reads better on it?
|
||||
pub(crate) fn is_dark_fill(fill: Color32) -> bool {
|
||||
contrast_ratio(Color32::WHITE, fill) > contrast_ratio(Color32::BLACK, fill)
|
||||
}
|
||||
|
||||
impl TabAccent {
|
||||
pub fn for_tab(t: &Tokens, tab: &str) -> Self {
|
||||
let (color, ink, soft) = match tab {
|
||||
@@ -872,6 +894,17 @@ impl Tokens {
|
||||
egui::Shadow { offset: [0, self.space_2 as i8], blur: self.space_8 as u8, spread: 0, color: self.app_bar.gamma_multiply(0.18) }
|
||||
}
|
||||
|
||||
/// The accent ink (`form_primary`) on `fill`, or the theme's selected or body ink where the
|
||||
/// accent does not read there: a theme whose selection or highlight is its accent colour.
|
||||
pub(crate) fn accent_on(&self, fill: Color32) -> Color32 {
|
||||
readable(&[fill], MARK_CONTRAST, &[self.form_primary, self.selected_ink, self.form_ink])
|
||||
}
|
||||
|
||||
/// `ink` on the selected fill, or `selected_ink` where `ink` does not read at `min` there.
|
||||
pub(crate) fn on_selected(&self, ink: Color32, min: f64) -> Color32 {
|
||||
readable(&[self.selected_bg], min, &[ink, self.selected_ink])
|
||||
}
|
||||
|
||||
/// The X, Y and Z axis colours, in that order.
|
||||
pub const fn axes(&self) -> [Color32; 3] {
|
||||
[self.axis_x, self.axis_y, self.axis_z]
|
||||
|
||||
@@ -509,10 +509,38 @@ pub fn resolve(theme: &ThemeFile) -> Tokens {
|
||||
// A file that sets no colours is its base palette, exactly.
|
||||
if !theme.colors.is_empty() {
|
||||
file_view_from_theme(&mut t, theme.base.is_dark(), |k| theme.colors.contains_key(k));
|
||||
navigation_from_theme(&mut t, |k| theme.colors.contains_key(k));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// The navigation bar and its menus are newer than most theme files: the navigation colours a
|
||||
/// theme leaves unset follow its own hover, dim text, control and dialog colours instead of its
|
||||
/// base's blue-greys, and its tooltip is the theme's ink and dialog colours turned around.
|
||||
fn navigation_from_theme(t: &mut Tokens, set: impl Fn(&str) -> bool) {
|
||||
if !set("nav_hover") {
|
||||
t.nav_hover = t.hover;
|
||||
}
|
||||
if !set("nav_press") {
|
||||
t.nav_press = t.nav_hover.lerp_to_gamma(t.form_ink, 0.12);
|
||||
}
|
||||
if !set("nav_dim") {
|
||||
t.nav_dim = t.text_dim;
|
||||
}
|
||||
if !set("nav_field") {
|
||||
t.nav_field = t.control_fill;
|
||||
}
|
||||
if !set("nav_viewport") {
|
||||
t.nav_viewport = t.dialog_body;
|
||||
}
|
||||
if !set("nav_tip") {
|
||||
t.nav_tip = t.form_ink;
|
||||
}
|
||||
if !set("nav_tip_ink") {
|
||||
t.nav_tip_ink = t.dialog_body;
|
||||
}
|
||||
}
|
||||
|
||||
/// The File view's colours a theme leaves unset follow the theme's own panel, border, accent and
|
||||
/// text, not its base's fixed blue rail and grey cards (#4868). Each ink is whichever candidate
|
||||
/// reads best on the fills it sits on.
|
||||
|
||||
+768
-21
@@ -290,7 +290,13 @@ pub(crate) fn offer_pill(
|
||||
let glyph = Rect::from_center_size(pos2(glyph_x, rect.center().y), vec2(16.0, 16.0));
|
||||
icons::paint_on(ui.painter(), glyph, icon, Some(true), 1.0);
|
||||
if let Some((text, _)) = label {
|
||||
ui.painter().text(pos2(rect.left() + text_x, rect.center().y), Align2::LEFT_CENTER, text, font, t.app_bar_text);
|
||||
// The pill is the theme's accent: a light accent (the phosphor themes) needs dark ink.
|
||||
let ink = if hot {
|
||||
t.app_bar_text
|
||||
} else {
|
||||
crate::theme::readable(&[fill], crate::theme::MARK_CONTRAST, &[t.app_bar_text, t.on_accent, t.tab_on_fill_dark])
|
||||
};
|
||||
ui.painter().text(pos2(rect.left() + text_x, rect.center().y), Align2::LEFT_CENTER, text, font, ink);
|
||||
}
|
||||
Some((rect, response))
|
||||
}
|
||||
@@ -998,7 +1004,7 @@ fn persistent_pressed(app: &SolveApp, id: &str) -> bool {
|
||||
app.preferences.persistent_ribbon_pressed && persistent_command(id) && app.session.status_items().iter().any(|item| item.command == id)
|
||||
}
|
||||
|
||||
fn persistent_button_name(app: &SolveApp, command: &solidcraft_engine::CommandSpec) -> String {
|
||||
pub(crate) fn persistent_button_name(app: &SolveApp, command: &solidcraft_engine::CommandSpec) -> String {
|
||||
let info = crate::render_workspace::interactive_command_info(app, command);
|
||||
let label = crate::workspace::ribbon_button_label(command);
|
||||
app.tr_label(if label == command.label { info.label } else { label }).to_string()
|
||||
@@ -1567,6 +1573,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let gallery: Vec<_> = shown.iter().copied().filter(|c| app.toolbar_custom.in_gallery(&tab, panel, c.id)).collect();
|
||||
let large: Vec<_> = shown.iter().copied().filter(|c| !gallery.iter().any(|g| g.id == c.id)).collect();
|
||||
let drawn = if collapsed { 0 } else { plan.shown.min(button_widths.len()) };
|
||||
// The commands the ribbon shows as buttons now (a narrow window shows fewer), which
|
||||
// `ribbon_menus_skip_pinned` leaves out of the drop-down.
|
||||
let on_ribbon: Vec<&str> =
|
||||
large.iter().take(drawn).map(|c| c.id).chain(gallery.iter().filter(|_| !collapsed && drawn > large.len()).map(|c| c.id)).collect();
|
||||
// Buttons narrower than the panel's minimum width (a one-button panel) are centred.
|
||||
let buttons_width = button_widths.iter().take(drawn).sum::<f32>() + drawn.saturating_sub(1) as f32 * BUTTON_GAP;
|
||||
let inset = if drawn == 0 { 0.0 } else { ((width - 8.0 - buttons_width) / 2.0).max(0.0).floor() };
|
||||
@@ -1895,6 +1905,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let previewed = hidden_reveal.is_some_and(|r| r.preview && !open);
|
||||
if open || previewed {
|
||||
let cmds = if collapsed || trimmed { menu_commands(&cmds, &shown) } else { cmds.clone() };
|
||||
let cmds = if app.preferences.ribbon_menus_skip_pinned { skip_on_ribbon(cmds, &on_ribbon) } else { cmds };
|
||||
let area = Container::new(ui.ctx(), Surface::Popup)
|
||||
.area(popup_id)
|
||||
.fixed_pos(lr.left_bottom())
|
||||
@@ -1908,15 +1919,25 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
} else if cmds.is_empty() {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_label("Not available yet")).color(t.text_dim));
|
||||
}
|
||||
if sketch_create && app.preferences.ribbon_icon_grid && !cmds.is_empty() {
|
||||
let action = dropdown_grid(app, ui, &tab, &sketch_create_grouped(&cmds), &[]);
|
||||
grid_action(app, ui, action, &mut menu, &mut toggle);
|
||||
return;
|
||||
}
|
||||
if sketch_create {
|
||||
// One width for every row and the footer, as in the other panels.
|
||||
let row_w = dropdown_width(app, ui, &cmds);
|
||||
ui.set_width(row_w);
|
||||
sketch_create_menu(app, ui, &cmds, row_w, &mut menu, &mut toggle);
|
||||
section_footer(ui);
|
||||
section_footer(ui, "ui.ribbon_pin_footer");
|
||||
return;
|
||||
}
|
||||
let grouped = crate::workspace::grouped_commands(&tab, panel, &cmds);
|
||||
if app.preferences.ribbon_icon_grid && !grouped.is_empty() {
|
||||
let action = dropdown_grid(app, ui, &tab, &grouped, &crate::workspace::ungrouped(&tab, panel, &cmds));
|
||||
grid_action(app, ui, action, &mut menu, &mut toggle);
|
||||
return;
|
||||
}
|
||||
let cmds: Vec<_> = grouped.iter().map(|(_, c)| *c).collect();
|
||||
let row_w = dropdown_width(app, ui, &cmds);
|
||||
ui.set_width(row_w);
|
||||
@@ -1963,7 +1984,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
});
|
||||
section_footer(ui);
|
||||
section_footer(ui, "ui.ribbon_pin_footer");
|
||||
});
|
||||
});
|
||||
popup_rect = Some(area.response.rect);
|
||||
@@ -2206,26 +2227,31 @@ fn reveal_tooltip(app: &SolveApp, ui: &egui::Ui, rect: Rect, command: &solidcraf
|
||||
}
|
||||
}
|
||||
|
||||
/// A group heading inside a panel drop-down (Edges, Faces…; Line, Rectangle…), in the list and
|
||||
/// the grid: a step below the drop-down's own header (`font_caption`) and written as named, not
|
||||
/// in capitals, so it reads as part of the menu rather than above it.
|
||||
fn group_heading_font() -> FontId {
|
||||
crate::fonts::semibold(Tokens::get().font_micro)
|
||||
}
|
||||
|
||||
/// A group heading's text: its name in the interface language, as written.
|
||||
fn group_heading_text(label: &str) -> String {
|
||||
crate::i18n::tr_label(label).to_string()
|
||||
}
|
||||
|
||||
fn section_heading(ui: &mut egui::Ui, label: &str, width: f32) {
|
||||
let t = Tokens::get();
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover());
|
||||
ui.painter().text(
|
||||
rect.left_center() + vec2(6.0, 0.0),
|
||||
Align2::LEFT_CENTER,
|
||||
crate::i18n::tr_label(label).to_uppercase(),
|
||||
crate::fonts::semibold(crate::theme::Tokens::get().font_small),
|
||||
t.text_dim,
|
||||
);
|
||||
ui.painter().text(rect.left_center() + vec2(6.0, 0.0), Align2::LEFT_CENTER, group_heading_text(label), group_heading_font(), t.text_dim);
|
||||
}
|
||||
|
||||
fn section_footer(ui: &mut egui::Ui) {
|
||||
/// The note under a panel drop-down on how pinning works: `message` is its catalog id.
|
||||
fn section_footer(ui: &mut egui::Ui, message: &str) {
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(crate::i18n::tr("ui.ribbon_pin_footer")).size(crate::theme::Tokens::get().font_small).color(Tokens::get().text_dim),
|
||||
)
|
||||
.wrap(),
|
||||
egui::Label::new(egui::RichText::new(crate::i18n::tr(message)).size(crate::theme::Tokens::get().font_small).color(Tokens::get().text_dim))
|
||||
.wrap(),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
@@ -2338,6 +2364,360 @@ fn dropdown_row(
|
||||
RowAction::None
|
||||
}
|
||||
|
||||
/// Take up what was done in a grid drop-down, as for a list row: start the command (and close
|
||||
/// the drop-down), open its menu, or pin or unpin it.
|
||||
fn grid_action(
|
||||
app: &mut SolveApp,
|
||||
ui: &egui::Ui,
|
||||
action: Option<(&'static str, RowAction)>,
|
||||
menu: &mut Option<(egui::Pos2, &'static str)>,
|
||||
toggle: &mut Option<&'static str>,
|
||||
) {
|
||||
match action {
|
||||
Some((id, RowAction::Menu(at))) => *menu = Some((at, id)),
|
||||
Some((id, RowAction::TogglePin)) => *toggle = Some(id),
|
||||
Some((id, RowAction::Start(rect))) => {
|
||||
app.start_at(id, Some(rect));
|
||||
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
|
||||
}
|
||||
Some((_, RowAction::None)) | None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch › Create in the grid: each family of variants under its own heading (Line, Rectangle,
|
||||
/// Circle…), then the other tools under "More". The list keeps its family sub-menus.
|
||||
fn sketch_create_grouped(cmds: &[&'static solidcraft_engine::CommandSpec]) -> Vec<(Option<&'static str>, &'static solidcraft_engine::CommandSpec)> {
|
||||
let mut out = Vec::new();
|
||||
for f in crate::sketch_families::FAMILIES {
|
||||
let mut heading = Some(f.label);
|
||||
for id in f.commands {
|
||||
if let Some(c) = cmds.iter().find(|c| c.id == *id) {
|
||||
out.push((heading.take(), *c));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut heading = Some("More");
|
||||
for c in cmds.iter().filter(|c| crate::sketch_families::family(c.id).is_none()) {
|
||||
out.push((heading.take(), *c));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A drop-down's commands without those the ribbon shows as buttons (`ribbon_menus_skip_pinned`),
|
||||
/// unless that leaves none.
|
||||
fn skip_on_ribbon(cmds: Vec<&'static solidcraft_engine::CommandSpec>, on_ribbon: &[&str]) -> Vec<&'static solidcraft_engine::CommandSpec> {
|
||||
let rest: Vec<_> = cmds.iter().copied().filter(|c| !on_ribbon.contains(&c.id)).collect();
|
||||
if rest.is_empty() { cmds } else { rest }
|
||||
}
|
||||
|
||||
/// A grid drop-down's cell (#4739): the icon twice the size of a list row's, with room around it.
|
||||
const GRID_CELL: f32 = 48.0;
|
||||
const GRID_ICON: f32 = 32.0;
|
||||
/// Columns a grid fills before a second row; a panel with fewer commands is as wide as it needs.
|
||||
const GRID_COLUMNS: usize = 5;
|
||||
/// Rows at most: a bigger menu grows to the right instead (unless the window is too narrow).
|
||||
const GRID_ROWS: usize = 3;
|
||||
|
||||
/// The height of a group's heading in a grid drop-down.
|
||||
const GRID_HEADING: f32 = 24.0;
|
||||
/// Icons side by side in a group's column, when groups stand side by side.
|
||||
const GROUP_COLUMNS: usize = 2;
|
||||
|
||||
/// Where a grid drop-down puts things, relative to its top left: each command's icon with its
|
||||
/// (row, column) for the arrow keys, each group heading (where, how wide), the separators
|
||||
/// between groups side by side (their x), and the whole size.
|
||||
struct GridLayout {
|
||||
cells: Vec<(Rect, (usize, usize))>,
|
||||
headings: Vec<(egui::Pos2, f32, &'static str)>,
|
||||
separators: Vec<f32>,
|
||||
size: egui::Vec2,
|
||||
}
|
||||
|
||||
/// Lay out a grid drop-down, at most [`GRID_ROWS`] high, growing to the right. A menu of two or
|
||||
/// more headed groups (Sketch › Create's families, Model › Modify's Edges, Faces…) puts the
|
||||
/// groups side by side, each heading over a column of its icons, [`GROUP_COLUMNS`] across (more
|
||||
/// for a group too big for three rows), when that fits in `room` (wide). Any other menu, or one
|
||||
/// too wide for the window, fills rows: [`GRID_COLUMNS`] across, more when that would take over
|
||||
/// three rows, fewer when the window is narrower; a heading starts a new row. `loose` names the
|
||||
/// commands in no group (Model › Modify's Delete): they stand apart after the groups, without a
|
||||
/// heading, so they stay put when a group is left out (`ribbon_menus_skip_pinned`).
|
||||
fn grid_layout(grouped: &[(Option<&'static str>, &'static solidcraft_engine::CommandSpec)], loose: &[&str], room: f32) -> GridLayout {
|
||||
let mut groups: Vec<(Option<&'static str>, Vec<usize>)> = Vec::new();
|
||||
for (i, (heading, c)) in grouped.iter().enumerate() {
|
||||
let after_a_group = groups.last().is_some_and(|(h, _)| h.is_some());
|
||||
if heading.is_some() || groups.is_empty() || (after_a_group && loose.contains(&c.id)) {
|
||||
groups.push((*heading, Vec::new()));
|
||||
}
|
||||
if let Some((_, items)) = groups.last_mut() {
|
||||
items.push(i);
|
||||
}
|
||||
}
|
||||
// A heading needs two icons' width; loose commands only as many columns as they fill.
|
||||
let group_columns = |heading: Option<&str>, n: usize| n.div_ceil(GRID_ROWS).max(if heading.is_some() { GROUP_COLUMNS } else { 1 });
|
||||
let headed = groups.iter().filter(|(h, _)| h.is_some()).count();
|
||||
let side_columns: usize = groups.iter().map(|(h, items)| group_columns(*h, items.len())).sum();
|
||||
let side_by_side = headed >= 2 && side_columns as f32 * GRID_CELL <= room;
|
||||
let mut cells = vec![(Rect::NOTHING, (0, 0)); grouped.len()];
|
||||
let mut headings = Vec::new();
|
||||
let mut separators = Vec::new();
|
||||
let cell = |x: f32, y: f32| Rect::from_min_size(pos2(x, y), vec2(GRID_CELL, GRID_CELL));
|
||||
let size = if side_by_side {
|
||||
let (mut first_column, mut rows) = (0, 0);
|
||||
for (g, (heading, items)) in groups.iter().enumerate() {
|
||||
let columns = group_columns(*heading, items.len());
|
||||
let x = first_column as f32 * GRID_CELL;
|
||||
if g > 0 {
|
||||
separators.push(x);
|
||||
}
|
||||
if let Some(h) = heading {
|
||||
headings.push((pos2(x, 0.0), columns as f32 * GRID_CELL, *h));
|
||||
}
|
||||
for (k, i) in items.iter().enumerate() {
|
||||
let (row, column) = (k / columns, k % columns);
|
||||
if let Some(slot) = cells.get_mut(*i) {
|
||||
*slot = (cell(x + column as f32 * GRID_CELL, GRID_HEADING + row as f32 * GRID_CELL), (row, first_column + column));
|
||||
}
|
||||
}
|
||||
rows = rows.max(items.len().div_ceil(columns));
|
||||
first_column += columns;
|
||||
}
|
||||
vec2(first_column as f32 * GRID_CELL, GRID_HEADING + rows as f32 * GRID_CELL)
|
||||
} else {
|
||||
let n = grouped.len();
|
||||
let fit = ((room / GRID_CELL).floor() as usize).max(1);
|
||||
let columns = n.div_ceil(GRID_ROWS).max(n.min(GRID_COLUMNS)).min(fit).max(1);
|
||||
let (mut y, mut first_row) = (0.0, 0);
|
||||
for (heading, items) in &groups {
|
||||
if let Some(h) = heading {
|
||||
headings.push((pos2(0.0, y), columns as f32 * GRID_CELL, *h));
|
||||
y += GRID_HEADING;
|
||||
}
|
||||
for (k, i) in items.iter().enumerate() {
|
||||
let (row, column) = (k / columns, k % columns);
|
||||
if let Some(slot) = cells.get_mut(*i) {
|
||||
*slot = (cell(column as f32 * GRID_CELL, y + row as f32 * GRID_CELL), (first_row + row, column));
|
||||
}
|
||||
}
|
||||
let rows = items.len().div_ceil(columns);
|
||||
y += rows as f32 * GRID_CELL;
|
||||
first_row += rows;
|
||||
}
|
||||
vec2(columns as f32 * GRID_CELL, y)
|
||||
};
|
||||
GridLayout { cells, headings, separators, size }
|
||||
}
|
||||
|
||||
/// An arrow key in a grid drop-down.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum GridKey {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
/// Where an arrow key moves the focus in a grid drop-down, as an index into `enabled` (the
|
||||
/// enabled commands, as indices into `cells`): Left and Right step through them in order, Up and
|
||||
/// Down to the nearest one in the row above or below, all wrapping. From none focused, Right
|
||||
/// and Down go to the first, Left and Up to the last.
|
||||
fn grid_step(cells: &[(usize, usize)], enabled: &[usize], at: Option<usize>, key: GridKey) -> Option<usize> {
|
||||
let n = enabled.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let Some(at) = at.filter(|at| *at < n) else {
|
||||
return Some(if matches!(key, GridKey::Right | GridKey::Down) { 0 } else { n - 1 });
|
||||
};
|
||||
let cell = |k: usize| enabled.get(k).and_then(|i| cells.get(*i)).copied();
|
||||
match key {
|
||||
GridKey::Right => Some((at + 1) % n),
|
||||
GridKey::Left => Some((at + n - 1) % n),
|
||||
GridKey::Up | GridKey::Down => {
|
||||
let (row, column) = cell(at)?;
|
||||
let rows: Vec<usize> = (0..n).filter_map(|k| cell(k).map(|c| c.0)).collect();
|
||||
let target = if key == GridKey::Down {
|
||||
rows.iter().copied().filter(|r| *r > row).min().or_else(|| rows.iter().copied().min())
|
||||
} else {
|
||||
rows.iter().copied().filter(|r| *r < row).max().or_else(|| rows.iter().copied().max())
|
||||
}?;
|
||||
(0..n)
|
||||
.filter_map(|k| cell(k).filter(|c| c.0 == target).map(|c| (k, c.1.abs_diff(column))))
|
||||
.min_by_key(|(_, distance)| *distance)
|
||||
.map(|(k, _)| k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arrow keys move the keyboard focus through a grid drop-down's enabled icons ([`grid_step`]).
|
||||
fn grid_keys(app: &SolveApp, ui: &egui::Ui, cmds: &[&'static solidcraft_engine::CommandSpec], cells: &[(usize, usize)]) {
|
||||
let key = ui.input(|i| {
|
||||
[
|
||||
(egui::Key::ArrowLeft, GridKey::Left),
|
||||
(egui::Key::ArrowRight, GridKey::Right),
|
||||
(egui::Key::ArrowUp, GridKey::Up),
|
||||
(egui::Key::ArrowDown, GridKey::Down),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|(k, _)| i.key_pressed(*k))
|
||||
.map(|(_, key)| key)
|
||||
});
|
||||
let Some(key) = key else { return };
|
||||
let enabled: Vec<usize> = cmds.iter().enumerate().filter(|(_, c)| c.info(&app.session).enabled).map(|(i, _)| i).collect();
|
||||
let focused = ui.ctx().memory(|m| m.focused());
|
||||
let at = enabled.iter().position(|i| cmds.get(*i).is_some_and(|c| focused == Some(dropdown_row_id(c.id))));
|
||||
if let Some(c) = grid_step(cells, &enabled, at, key).and_then(|k| enabled.get(k)).and_then(|i| cmds.get(*i)) {
|
||||
ui.ctx().memory_mut(|m| {
|
||||
m.request_focus(dropdown_row_id(c.id));
|
||||
m.move_focus(egui::FocusDirection::None);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel drop-down drawn as a grid of large icons (the `ribbon_icon_grid` preference, #4739):
|
||||
/// the same commands, headings and actions as the list, the names in the tooltips. Returns the
|
||||
/// command acted on, and how.
|
||||
fn dropdown_grid(
|
||||
app: &SolveApp,
|
||||
ui: &mut egui::Ui,
|
||||
tab: &str,
|
||||
grouped: &[(Option<&'static str>, &'static solidcraft_engine::CommandSpec)],
|
||||
loose: &[&str],
|
||||
) -> Option<(&'static str, RowAction)> {
|
||||
// Wide enough for groups side by side, short of the window's edge.
|
||||
let room = ui.ctx().content_rect().right() - ui.cursor().left() - 24.0;
|
||||
let layout = grid_layout(grouped, loose, room);
|
||||
let width = layout.size.x.max(200.0);
|
||||
// A narrow grid sits in the middle of the drop-down's least width.
|
||||
let inset = ((width - layout.size.x) / 2.0).max(0.0);
|
||||
ui.set_width(width);
|
||||
let cmds: Vec<_> = grouped.iter().map(|(_, c)| *c).collect();
|
||||
let keys: Vec<(usize, usize)> = layout.cells.iter().map(|(_, at)| *at).collect();
|
||||
grid_keys(app, ui, &cmds, &keys);
|
||||
// Like the list, the grid scrolls when it would run past the window's bottom (#874).
|
||||
let height = (ui.ctx().content_rect().bottom() - ui.cursor().top() - 64.0).max(GRID_CELL * 2.0);
|
||||
let mut action = None;
|
||||
egui::ScrollArea::vertical().id_salt("panel_menu_grid").max_height(height).min_scrolled_height(height).show(ui, |ui| {
|
||||
let (area, _) = ui.allocate_exact_size(vec2(width, layout.size.y), Sense::hover());
|
||||
let origin = area.left_top() + vec2(inset, 0.0);
|
||||
let t = Tokens::get();
|
||||
for (at, w, heading) in &layout.headings {
|
||||
let text = group_heading_text(heading);
|
||||
let clip = ui.clip_rect().intersect(Rect::from_min_size(origin + at.to_vec2(), vec2(*w, GRID_HEADING)));
|
||||
let painter = ui.painter().with_clip_rect(clip);
|
||||
// Centred over its group's icons.
|
||||
painter.text(origin + at.to_vec2() + vec2(w / 2.0, GRID_HEADING / 2.0), Align2::CENTER_CENTER, text, group_heading_font(), t.text_dim);
|
||||
}
|
||||
// A line between groups side by side, as between the ribbon's sections.
|
||||
for x in &layout.separators {
|
||||
let x = origin.x + x;
|
||||
ui.painter().line_segment([pos2(x, origin.y + 4.0), pos2(x, origin.y + layout.size.y - 4.0)], Stroke::new(1.0, t.border));
|
||||
}
|
||||
for ((_, c), (rect, _)) in grouped.iter().zip(&layout.cells) {
|
||||
let rect = rect.translate(origin.to_vec2());
|
||||
let pinned = crate::toolbar_custom::pinned_on(app, tab, c.id).is_some();
|
||||
match dropdown_cell(app, ui, c, rect, pinned) {
|
||||
RowAction::None => {}
|
||||
done => action = Some((c.id, done)),
|
||||
}
|
||||
}
|
||||
});
|
||||
action
|
||||
}
|
||||
|
||||
/// How long the pointer rests on a grid icon before its corner pin can be clicked (seconds).
|
||||
const PIN_DELAY: f64 = 2.0;
|
||||
|
||||
/// Whether the corner pin of grid icon `id` takes clicks: the pointer (`on`) has rested on the
|
||||
/// icon for [`PIN_DELAY`]. One icon is timed at a time; the window redraws when the pin is due.
|
||||
fn pin_armed(ui: &egui::Ui, id: &'static str, on: bool) -> bool {
|
||||
let key = egui::Id::new("sc_grid_pin_hover");
|
||||
let now = ui.input(|i| i.time);
|
||||
let since = ui.ctx().data_mut(|d| {
|
||||
let rest = d.get_temp::<(&'static str, f64)>(key).filter(|(at, _)| *at == id);
|
||||
match (on, rest) {
|
||||
(true, Some((_, since))) => Some(since),
|
||||
(true, None) => {
|
||||
d.insert_temp(key, (id, now));
|
||||
Some(now)
|
||||
}
|
||||
(false, Some(_)) => {
|
||||
d.remove::<(&'static str, f64)>(key);
|
||||
None
|
||||
}
|
||||
(false, None) => None,
|
||||
}
|
||||
});
|
||||
let Some(since) = since else { return false };
|
||||
let left = PIN_DELAY - (now - since);
|
||||
if left > 0.0 {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(left));
|
||||
}
|
||||
left <= 0.0
|
||||
}
|
||||
|
||||
/// One icon of a grid drop-down: clicked, dragged onto the ribbon (to pin it) or right-clicked
|
||||
/// like a list row; its tooltip is a ribbon button's (name, shortcut, help, why it is off), and a
|
||||
/// pin in its corner says it is on the ribbon.
|
||||
fn dropdown_cell(app: &SolveApp, ui: &mut egui::Ui, c: &'static solidcraft_engine::CommandSpec, rect: Rect, pinned: bool) -> RowAction {
|
||||
let t = Tokens::get();
|
||||
let info = c.info(&app.session);
|
||||
let sense = if info.enabled { Sense::click_and_drag() } else { Sense::drag() };
|
||||
let resp = ui.interact(rect, dropdown_row_id(c.id), sense);
|
||||
if resp.has_focus() && !ui.clip_rect().contains_rect(rect) {
|
||||
resp.scroll_to_me_animation(None, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
if resp.drag_started() {
|
||||
resp.dnd_set_drag_payload(ToolbarDrag { id: c.id, from_menu: true });
|
||||
}
|
||||
// The pin in the corner, as at a list row's end. It takes clicks (above the cell) only once
|
||||
// the pointer has rested on the icon for PIN_DELAY: until then a click anywhere starts the
|
||||
// command, so a quick click near the corner never pins or unpins it by accident.
|
||||
let pr = Rect::from_min_size(pos2(rect.right() - 15.0, rect.top()), vec2(15.0, 15.0));
|
||||
let dragging = egui::DragAndDrop::has_any_payload(ui.ctx());
|
||||
let armed = pin_armed(ui, c.id, ui.rect_contains_pointer(rect) && !dragging);
|
||||
let pin = armed.then(|| ui.interact(pr, ui.id().with(("dropdown_pin", c.id)), Sense::click()));
|
||||
let pin_hovered = pin.as_ref().is_some_and(egui::Response::hovered);
|
||||
let label = app.tr_label(c.label).to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, &label));
|
||||
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), rect.center());
|
||||
crate::scenario::publish_handle(&format!("dropdown:{}", c.id), rect.center());
|
||||
let pointer_on = (resp.hovered() || pin_hovered) && !dragging;
|
||||
let hot = info.enabled && (pointer_on || resp.has_focus());
|
||||
let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(rect));
|
||||
let cell = rect.shrink(2.0);
|
||||
if hot {
|
||||
painter.rect_filled(cell, t.control_radius, t.accent_soft);
|
||||
crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center());
|
||||
}
|
||||
if resp.has_focus() {
|
||||
painter.rect_stroke(cell, t.control_radius, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
icons::paint_icon(&painter, Rect::from_center_size(rect.center(), vec2(GRID_ICON, GRID_ICON)), c.icon, if info.enabled { 1.0 } else { 0.4 });
|
||||
// A pinned command always shows its pin, clear of the icon's own artwork; any other shows one
|
||||
// to pin it once it can be clicked.
|
||||
if pinned || armed {
|
||||
if pin_hovered {
|
||||
painter.rect_filled(pr, 3.0, t.border.gamma_multiply(0.6));
|
||||
}
|
||||
pin_icon(&painter, pr.shrink(2.5), pinned, if pinned { t.accent } else { t.text_dim });
|
||||
}
|
||||
if let Some(pin) = pin {
|
||||
crate::scenario::publish_handle(&format!("dropdown_pin:{}", c.id), pr.center());
|
||||
let tip = if pinned { "Remove from Toolbar" } else { "Pin to Toolbar" };
|
||||
if pin.on_hover_text(crate::i18n::tr_label(tip)).clicked() {
|
||||
return RowAction::TogglePin;
|
||||
}
|
||||
}
|
||||
let resp = ribbon_tooltip(app, resp, c, info.disabled_reason.as_deref());
|
||||
if resp.secondary_clicked() {
|
||||
return RowAction::Menu(resp.interact_pointer_pos().unwrap_or(rect.left_bottom()));
|
||||
}
|
||||
if resp.clicked() && info.enabled {
|
||||
return RowAction::Start(rect);
|
||||
}
|
||||
RowAction::None
|
||||
}
|
||||
|
||||
/// Creation menus keep related variants together, including collapsed and overflow panels.
|
||||
/// The plain rows are `width` wide, the width the menu gives its family rows.
|
||||
fn sketch_create_menu(
|
||||
@@ -2492,6 +2872,12 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if app.file_load.busy() {
|
||||
return;
|
||||
}
|
||||
if app.preferences.viewport_analysis_status
|
||||
&& (app.esc_handled || app.escape_owner.is_some())
|
||||
&& ctx.input(|input| input.key_pressed(egui::Key::Escape))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// File is a custom Area, so it must claim Escape before the underlying editors and
|
||||
// sketch shortcuts. Clear raw events too: egui may replay them on a layout pass.
|
||||
let file_menu_id = egui::Id::new("sc_file_menu");
|
||||
@@ -2517,17 +2903,39 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// These owners render after global shortcuts. Leave their Escape available to the
|
||||
// normal window/palette handler, but retain ownership if egui repeats the layout.
|
||||
if ctx.input(|input| input.key_pressed(egui::Key::Escape))
|
||||
&& (app.prefs_window.open || app.keymap.open || app.ui.palette_open || ctx.memory(|memory| memory.top_modal_layer().is_some()))
|
||||
{
|
||||
app.escape_owner = Some(crate::EscapeOwner::Overlay);
|
||||
if app.preferences.viewport_analysis_status {
|
||||
app.last_escape = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if crate::params_dialog::cancel_on_escape(app, ctx) {
|
||||
return;
|
||||
}
|
||||
// A dimension value that is taking the keyboard: letters belong to it (a parameter name
|
||||
// starting with L or D), even on the frame before its field holds the focus.
|
||||
if egui::Popup::is_any_open(ctx)
|
||||
|| ctx.egui_wants_keyboard_input()
|
||||
|| crate::dim_view::editing()
|
||||
|| crate::dialogs_save_mesh::popup_open(ctx)
|
||||
|| crate::hole_presets::name_prompt_open(ctx, app.dialog.as_ref())
|
||||
let popup_owner =
|
||||
egui::Popup::is_any_open(ctx) || crate::dialogs_save_mesh::popup_open(ctx) || crate::hole_presets::name_prompt_open(ctx, app.dialog.as_ref());
|
||||
let inline_editor = crate::dim_view::editing();
|
||||
if popup_owner
|
||||
|| inline_editor
|
||||
|| (app.preferences.viewport_analysis_status && app.ui.palette_open)
|
||||
|| (ctx.egui_wants_keyboard_input() && !(app.preferences.viewport_analysis_status && app.viewport_status.owns_focus(ctx)))
|
||||
{
|
||||
if ctx.input(|input| input.key_pressed(egui::Key::Escape)) {
|
||||
let owner = if app.dialog.is_some() && !popup_owner && !inline_editor { crate::EscapeOwner::Dialog } else { crate::EscapeOwner::Overlay };
|
||||
// Reserve first refusal without claiming that cancellation already occurred.
|
||||
// A dialog's own late handler must still be able to cancel its focused field.
|
||||
app.escape_owner.get_or_insert(owner);
|
||||
if app.preferences.viewport_analysis_status {
|
||||
app.last_escape = None;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (keys, mods) = ctx.input(|i| {
|
||||
@@ -2615,6 +3023,8 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
// Not while a tool is still out (its first Esc only ended a chain): this press
|
||||
// puts it down, and the next quick one finishes.
|
||||
if again
|
||||
&& !(app.preferences.viewport_analysis_status
|
||||
&& (app.viewport_status.open_key.is_some() || crate::viewport_status::has_effect(app)))
|
||||
&& app.tool.is_none()
|
||||
&& app.dialog.is_none()
|
||||
&& let Some(sketch) = app.session.active_sketch
|
||||
@@ -2639,6 +3049,8 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
crate::tools::finish(app);
|
||||
} else if app.dialog.is_some() || app.pending_feature_edit.is_some() {
|
||||
crate::dialogs::cancel(app);
|
||||
} else if app.preferences.viewport_analysis_status && crate::viewport_status::escape(app, repeat) {
|
||||
app.last_escape = None;
|
||||
} else if !app.session.selection.is_empty() || app.session.active_sketch.is_none() {
|
||||
crate::edit_clipboard::drop_cut(app);
|
||||
app.tree.picked_joints.clear();
|
||||
@@ -3659,6 +4071,341 @@ mod dropdown_tests {
|
||||
assert!(menu.contains(*at), "{id} at {at:?} is cut off by the drop-down {menu:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid's rows: a heading starts a new one, and the arrow keys move along and between
|
||||
/// them over the enabled icons only, wrapping at the ends (#4739).
|
||||
#[test]
|
||||
fn grid_rows_break_at_headings_and_arrow_keys_skip_disabled_icons() {
|
||||
let c = |id| solidcraft_engine::find_command(id).unwrap();
|
||||
let grouped = [
|
||||
(Some("Primitives"), c("solid.box")),
|
||||
(None, c("solid.cylinder")),
|
||||
(None, c("solid.sphere")),
|
||||
(None, c("solid.torus")),
|
||||
(None, c("solid.pipe")),
|
||||
(Some("Other"), c("solid.extrude")),
|
||||
(None, c("solid.revolve")),
|
||||
];
|
||||
// Too narrow for the groups side by side (two icons wide): rows as wide as the window, a
|
||||
// heading starting a row.
|
||||
let rows = grid_layout(&grouped, &[], 100.0);
|
||||
let at = |l: &GridLayout| l.cells.iter().map(|(_, at)| *at).collect::<Vec<_>>();
|
||||
assert_eq!(at(&rows), [(0, 0), (0, 1), (1, 0), (1, 1), (2, 0), (3, 0), (3, 1)]);
|
||||
assert_eq!(rows.headings.len(), 2);
|
||||
// Room enough: each group a column of two under its heading, the groups side by side.
|
||||
let side = grid_layout(&grouped, &[], 1000.0);
|
||||
assert_eq!(at(&side), [(0, 0), (0, 1), (1, 0), (1, 1), (2, 0), (0, 2), (0, 3)]);
|
||||
let headings: Vec<_> = side.headings.iter().map(|(p, _, h)| (p.x, *h)).collect();
|
||||
assert_eq!(headings, [(0.0, "Primitives"), (2.0 * GRID_CELL, "Other")]);
|
||||
assert_eq!(side.size, vec2(4.0 * GRID_CELL, GRID_HEADING + 3.0 * GRID_CELL));
|
||||
assert_eq!(side.separators, [2.0 * GRID_CELL], "a line between the two groups");
|
||||
assert!(rows.separators.is_empty(), "rows have none");
|
||||
assert!(side.cells.iter().all(|(r, _)| r.top() >= GRID_HEADING), "icons below the headings");
|
||||
// A menu without groups keeps its rows of five.
|
||||
let plain: Vec<_> = grouped.iter().map(|(_, c)| (None, *c)).collect();
|
||||
assert_eq!(at(&grid_layout(&plain, &[], 1000.0)), [(0, 0), (0, 1), (0, 2), (0, 3), (0, 4), (1, 0), (1, 1)]);
|
||||
let cells = at(&side);
|
||||
// `solid.pipe` (index 4) is disabled: `enabled` holds the others.
|
||||
let enabled = [0, 1, 2, 3, 5, 6];
|
||||
let step = |at, key| grid_step(&cells, &enabled, at, key).map(|k: usize| enabled[k]);
|
||||
assert_eq!((step(None, GridKey::Right), step(None, GridKey::Down)), (Some(0), Some(0)));
|
||||
assert_eq!((step(None, GridKey::Left), step(None, GridKey::Up)), (Some(6), Some(6)));
|
||||
assert_eq!(step(Some(2), GridKey::Right), Some(3), "Right runs on into the next row");
|
||||
assert_eq!(step(Some(0), GridKey::Left), Some(6), "and wraps");
|
||||
assert_eq!(step(Some(1), GridKey::Down), Some(3), "Down goes to the nearest enabled icon below");
|
||||
assert_eq!(step(Some(4), GridKey::Down), Some(3), "from the next group's column too");
|
||||
assert_eq!(step(Some(2), GridKey::Down), Some(0), "from the last enabled row Down wraps to the first");
|
||||
assert_eq!(step(Some(5), GridKey::Up), Some(3), "from the first row Up wraps to the last, nearest column");
|
||||
assert_eq!(step(Some(1), GridKey::Up), Some(3));
|
||||
assert_eq!(grid_step(&cells, &[], None, GridKey::Down), None);
|
||||
}
|
||||
|
||||
/// A command in no group (Model › Modify's Delete) stands apart after the groups, without a
|
||||
/// heading: leaving out a pinned group (Specialise's Convert to Sheet Metal) must not move it
|
||||
/// under the group before (Bodies) (#4739).
|
||||
#[test]
|
||||
fn a_command_in_no_group_stays_apart_when_a_group_is_left_out() {
|
||||
let specs = solidcraft_engine::command_specs();
|
||||
let (cmds, _) = crate::workspace::layout("MODEL", "MODIFY", &specs).unwrap();
|
||||
let loose = crate::workspace::ungrouped("MODEL", "MODIFY", &cmds);
|
||||
assert_eq!(loose, ["timeline.delete"]);
|
||||
let column_of = |cmds: &[&'static solidcraft_engine::CommandSpec], id: &str| {
|
||||
let grouped = crate::workspace::grouped_commands("MODEL", "MODIFY", cmds);
|
||||
let layout = grid_layout(&grouped, &loose, 4000.0);
|
||||
let x = |id: &str| grouped.iter().position(|(_, c)| c.id == id).and_then(|i| layout.cells.get(i)).map(|(r, _)| r.left());
|
||||
let last_group = layout.separators.last().copied();
|
||||
(x(id), last_group)
|
||||
};
|
||||
let (delete, separator) = column_of(&cmds, "timeline.delete");
|
||||
assert_eq!(delete, separator, "Delete in a column of its own after Specialise");
|
||||
let skipped = skip_on_ribbon(cmds.clone(), &["sheet.convert"]);
|
||||
let (delete, separator) = column_of(&skipped, "timeline.delete");
|
||||
let (remove, _) = column_of(&skipped, "solid.remove");
|
||||
assert_eq!(delete, separator, "still apart without Specialise");
|
||||
assert!(delete > remove, "not under Bodies: {delete:?} vs {remove:?}");
|
||||
}
|
||||
|
||||
/// Group headings sit below the drop-down's own header in the hierarchy: a smaller font than
|
||||
/// the header's, and written as named rather than in capitals (list and grid alike).
|
||||
#[test]
|
||||
fn group_headings_are_smaller_than_the_drop_down_header() {
|
||||
let t = Tokens::get();
|
||||
assert!(group_heading_font().size < t.font_caption, "{} vs the header's {}", group_heading_font().size, t.font_caption);
|
||||
assert_eq!(group_heading_text("Edges"), "Edges", "written as named, not in capitals");
|
||||
}
|
||||
|
||||
/// Every panel's drop-down of every tab, and Sketch › Create's families, is at most
|
||||
/// GRID_ROWS high in the grid when the window is wide enough: big menus grow to the right.
|
||||
#[test]
|
||||
fn every_grid_menu_is_at_most_three_rows_high() {
|
||||
let rows = |l: &GridLayout| l.cells.iter().map(|(r, _)| r.top().round() as i32).collect::<std::collections::BTreeSet<_>>().len();
|
||||
let tabs: std::collections::BTreeSet<&str> = crate::workspace::SECTIONS.iter().map(|s| s.tab).collect();
|
||||
for tab in tabs {
|
||||
for (panel, cmds, _) in crate::toolbar_custom::panel_lists(tab) {
|
||||
let grouped = crate::workspace::grouped_commands(tab, panel, &cmds);
|
||||
let layout = grid_layout(&grouped, &crate::workspace::ungrouped(tab, panel, &cmds), 4000.0);
|
||||
assert!(rows(&layout) <= GRID_ROWS, "{tab} {panel}: {} rows", rows(&layout));
|
||||
if tab == "SKETCH" && panel == "CREATE" {
|
||||
let families = grid_layout(&sketch_create_grouped(&cmds), &[], 4000.0);
|
||||
assert!(rows(&families) <= GRID_ROWS, "Sketch › Create families: {} rows", rows(&families));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A window too narrow for the width wraps into more rows instead of running off it.
|
||||
let c = |id| solidcraft_engine::find_command(id).unwrap();
|
||||
let many: Vec<_> = solidcraft_engine::command_specs().into_iter().take(30).map(|c| (None, c)).collect();
|
||||
let narrow = grid_layout(&many, &[], 4.0 * GRID_CELL);
|
||||
assert!(narrow.size.x <= 4.0 * GRID_CELL && rows(&narrow) > GRID_ROWS);
|
||||
let few = [(None, c("solid.box")), (None, c("solid.sphere"))];
|
||||
assert_eq!(grid_layout(&few, &[], 4000.0).size, vec2(2.0 * GRID_CELL, GRID_CELL), "a small menu is one row");
|
||||
}
|
||||
|
||||
/// With `ribbon_icon_grid` on, a panel's drop-down shows the same commands as its list, as
|
||||
/// large icons in rows of at most five; arrow keys move across and down; a click starts the
|
||||
/// command, and a drag pins it, as from the list (#4739).
|
||||
#[test]
|
||||
fn the_icon_grid_shows_the_lists_commands_and_acts_like_it() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.resize", json!({"width":1600,"height":1000}));
|
||||
let open = |h: &mut crate::scenario::Harness| {
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"handle":"panel:MODEL:CREATE"}})).unwrap();
|
||||
h.frames(6);
|
||||
let menu = crate::scenario::rect_of("toolbar_dropdown").expect("the drop-down is open");
|
||||
(menu, crate::scenario::handles("dropdown:"))
|
||||
};
|
||||
let (_, list) = open(&mut h);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(!h.app.preferences.ribbon_icon_grid, "the list stays the default");
|
||||
h.call("ui.prefs", json!({"ribbon_icon_grid": true}));
|
||||
assert!(h.app.preferences.ribbon_icon_grid);
|
||||
let (menu, grid) = open(&mut h);
|
||||
let ids = |rows: &[(String, egui::Pos2)]| rows.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&grid), ids(&list), "the grid shows the list's commands in its order");
|
||||
assert!(grid.len() > GRID_COLUMNS * GRID_ROWS, "MODEL CREATE is a big menu: {grid:?}");
|
||||
for (id, at) in &grid {
|
||||
assert!(menu.contains(*at), "{id} at {at:?} is outside the drop-down {menu:?}");
|
||||
}
|
||||
let ys: std::collections::BTreeSet<i32> = grid.iter().map(|(_, at)| at.y.round() as i32).collect();
|
||||
assert_eq!(ys.len(), GRID_ROWS, "laid out in {GRID_ROWS} rows, growing to the right: {ys:?}");
|
||||
let columns = grid.len().div_ceil(GRID_ROWS);
|
||||
assert!(menu.width() <= columns as f32 * GRID_CELL + 40.0, "no wider than its columns: {menu:?}");
|
||||
|
||||
let hot = |h: &mut crate::scenario::Harness, key: &str| {
|
||||
h.step(&json!({"key": key})).unwrap();
|
||||
h.frames(3);
|
||||
let hot = crate::scenario::handles("dropdown_hot:");
|
||||
let [(_, at)] = hot.as_slice() else { panic!("one highlighted icon after {key}: {hot:?}") };
|
||||
*at
|
||||
};
|
||||
let first = hot(&mut h, "ArrowRight");
|
||||
let second = hot(&mut h, "ArrowRight");
|
||||
assert!(second.x > first.x && (second.y - first.y).abs() < 1.0, "Right moves along the row: {first:?} → {second:?}");
|
||||
let below = hot(&mut h, "ArrowDown");
|
||||
assert!(below.y > second.y, "Down moves to the row below: {second:?} → {below:?}");
|
||||
|
||||
h.step(&json!({"click":{"handle":"dropdown:solid.box"}})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(crate::scenario::rect_of("toolbar_dropdown").is_none(), "a click closes the drop-down");
|
||||
assert!(h.app.dialog.is_some() || h.app.tool.is_some(), "and starts the command");
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.frames(3);
|
||||
|
||||
open(&mut h);
|
||||
let label = crate::scenario::handle_at("panel:MODEL:CREATE").unwrap();
|
||||
h.step(&json!({"drag":[{"handle":"dropdown:solid.cylinder"}, [label.x, label.y]],"steps":12})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(crate::toolbar_custom::pinned_on(&h.app, "MODEL", "solid.cylinder").is_some(), "dragged onto the ribbon, it is pinned");
|
||||
assert!(h.app.session.doc.features.is_empty(), "pinning does not run the command");
|
||||
}
|
||||
|
||||
/// A right-click on a drop-down's command, in the list and in the grid, opens its menu:
|
||||
/// Pin to Toolbar, Remove from Toolbar and Pin to Shortcuts act on that command.
|
||||
#[test]
|
||||
fn a_right_click_in_the_drop_down_pins_and_unpins() {
|
||||
for grid in [false, true] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.resize", json!({"width":1600,"height":1000}));
|
||||
h.app.preferences.ribbon_icon_grid = grid;
|
||||
let pick = |h: &mut crate::scenario::Harness, item: &str| {
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"handle":"panel:MODEL:CREATE"}})).unwrap();
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"handle":"dropdown:solid.box"},"button":"right"})).unwrap();
|
||||
h.frames(4);
|
||||
assert!(h.app.viewport.context_menu.is_some(), "grid {grid}: the right-click opens the command's menu");
|
||||
h.step(&json!({"widget": item})).unwrap_or_else(|e| panic!("grid {grid}: {item}: {e}"));
|
||||
h.frames(4);
|
||||
};
|
||||
pick(&mut h, "Pin to Toolbar");
|
||||
assert!(crate::toolbar_custom::pinned_on(&h.app, "MODEL", "solid.box").is_some(), "grid {grid}: pinned");
|
||||
pick(&mut h, "Remove from Toolbar");
|
||||
assert!(crate::toolbar_custom::pinned_on(&h.app, "MODEL", "solid.box").is_none(), "grid {grid}: unpinned");
|
||||
pick(&mut h, "Pin to Shortcuts");
|
||||
assert!(h.app.sbox.pinned.iter().any(|p| p == "solid.box"), "grid {grid}: a shortcut");
|
||||
// The pin shown on the pointed-at row or icon pins it, and again unpins it. A grid
|
||||
// icon's pin takes clicks only after the pointer has rested on it for PIN_DELAY.
|
||||
for pinned in [true, false] {
|
||||
h.step(&json!({"click":{"handle":"panel:MODEL:CREATE"}})).unwrap();
|
||||
h.frames(4);
|
||||
h.step(&json!({"move":{"handle":"dropdown:solid.box"}})).unwrap();
|
||||
h.frames(3);
|
||||
if grid {
|
||||
assert!(crate::scenario::handle_at("dropdown_pin:solid.box").is_none(), "no pin to click straight away");
|
||||
h.frames((PIN_DELAY * 60.0) as usize + 5);
|
||||
}
|
||||
h.step(&json!({"click":{"handle":"dropdown_pin:solid.box"}})).unwrap();
|
||||
h.frames(3);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert_eq!(crate::toolbar_custom::pinned_on(&h.app, "MODEL", "solid.box").is_some(), pinned, "grid {grid}: the hover pin");
|
||||
}
|
||||
assert!(h.app.session.doc.features.is_empty(), "grid {grid}: pinning does not run the command");
|
||||
}
|
||||
}
|
||||
|
||||
/// A quick click on a grid icon's corner, where its pin appears after PIN_DELAY, starts the
|
||||
/// command instead of pinning it.
|
||||
#[test]
|
||||
fn a_quick_click_on_the_pin_corner_starts_the_command() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.resize", json!({"width":1600,"height":1000}));
|
||||
h.app.preferences.ribbon_icon_grid = true;
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"handle":"panel:MODEL:CREATE"}})).unwrap();
|
||||
h.frames(4);
|
||||
let at = crate::scenario::handle_at("dropdown:solid.box").unwrap() + vec2(GRID_CELL / 2.0 - 7.0, -GRID_CELL / 2.0 + 7.0);
|
||||
h.step(&json!({"click":[at.x, at.y]})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(crate::toolbar_custom::pinned_on(&h.app, "MODEL", "solid.box").is_none(), "not pinned");
|
||||
assert!(h.app.dialog.is_some() || h.app.tool.is_some(), "the command starts");
|
||||
}
|
||||
|
||||
/// A command pinned from the grid becomes a ribbon button; Remove from Toolbar in the
|
||||
/// button's own right-click menu takes it off again, with both options on.
|
||||
#[test]
|
||||
fn a_pinned_button_is_removed_from_its_own_menu() {
|
||||
for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
|
||||
a_pinned_button_is_removed_in(mode);
|
||||
}
|
||||
}
|
||||
|
||||
fn a_pinned_button_is_removed_in(mode: crate::shell::LayoutMode) {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.set", json!({"layout":mode}));
|
||||
h.call("ui.resize", json!({"width":1600,"height":1000}));
|
||||
h.app.preferences.ribbon_icon_grid = true;
|
||||
h.app.preferences.ribbon_menus_skip_pinned = true;
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"handle":"panel:MODEL:CREATE"}})).unwrap();
|
||||
h.frames(4);
|
||||
h.step(&json!({"move":{"handle":"dropdown:solid.loft"}})).unwrap();
|
||||
h.frames((PIN_DELAY * 60.0) as usize + 5);
|
||||
h.step(&json!({"click":{"handle":"dropdown_pin:solid.loft"}})).unwrap();
|
||||
h.frames(3);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.frames(4);
|
||||
assert!(crate::scenario::handle_at("button:solid.loft").is_some(), "pinned, Loft is a ribbon button");
|
||||
h.step(&json!({"click":{"handle":"button:solid.loft"},"button":"right"})).unwrap();
|
||||
h.frames(4);
|
||||
assert!(h.app.viewport.context_menu.is_some(), "the button's menu opens");
|
||||
h.step(&json!({"widget":"Remove from Toolbar"})).unwrap();
|
||||
h.frames(4);
|
||||
assert!(crate::toolbar_custom::pinned_on(&h.app, "MODEL", "solid.loft").is_none(), "removed: {:?}", h.app.toolbar_custom.panels);
|
||||
assert!(crate::scenario::handle_at("button:solid.loft").is_none(), "and gone from the ribbon");
|
||||
}
|
||||
|
||||
/// Sketch › Create in the grid shows every variant of each family (Line, Rectangle, …) under
|
||||
/// the family's heading, then the other tools, as large icons; a click starts the tool.
|
||||
#[test]
|
||||
fn sketch_create_in_the_grid_shows_every_variant() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.resize", json!({"width":1600,"height":1000}));
|
||||
h.app.preferences.ribbon_icon_grid = true;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"handle":"panel:SKETCH:CREATE"}})).unwrap();
|
||||
h.frames(4);
|
||||
let cells: Vec<String> = crate::scenario::handles("dropdown:").into_iter().map(|(id, _)| id).collect();
|
||||
for id in ["sketch.line", "sketch.rectangle.center", "sketch.circle.three_point", "sketch.arc.tangent", "sketch.polygon.edge", "sketch.point"]
|
||||
{
|
||||
assert!(cells.iter().any(|c| *c == format!("dropdown:{id}")), "{id} is an icon of the grid: {cells:?}");
|
||||
}
|
||||
let grouped = sketch_create_grouped(
|
||||
&solidcraft_engine::command_specs().into_iter().filter(|c| c.id.starts_with("sketch.rectangle")).collect::<Vec<_>>(),
|
||||
);
|
||||
assert_eq!(grouped.first().and_then(|(h, _)| *h), Some("Rectangle"));
|
||||
assert!(grouped.iter().skip(1).all(|(h, _)| h.is_none()), "one heading per family");
|
||||
h.step(&json!({"click":{"handle":"dropdown:sketch.rectangle.center"}})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(crate::scenario::rect_of("toolbar_dropdown").is_none(), "a click closes the drop-down");
|
||||
assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some("sketch.rectangle.center"), "and starts the tool");
|
||||
}
|
||||
|
||||
/// With `ribbon_menus_skip_pinned`, a drop-down leaves out the commands the ribbon shows as
|
||||
/// buttons, in the list and in the grid; a panel too narrow for its buttons lists them again.
|
||||
#[test]
|
||||
fn pinned_commands_can_be_left_out_of_the_drop_down() {
|
||||
for grid in [false, true] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.resize", json!({"width":1600,"height":1000}));
|
||||
h.app.preferences.ribbon_icon_grid = grid;
|
||||
let rows = |h: &mut crate::scenario::Harness, panel: &str| {
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"handle":panel}})).unwrap();
|
||||
h.frames(4);
|
||||
let rows: Vec<String> = crate::scenario::handles("dropdown:").into_iter().map(|(id, _)| id).collect();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
rows
|
||||
};
|
||||
let all = rows(&mut h, "panel:MODEL:CREATE");
|
||||
assert!(all.iter().any(|id| id == "dropdown:solid.extrude"), "grid {grid}: listed by default: {all:?}");
|
||||
h.call("ui.prefs", json!({"ribbon_menus_skip_pinned": true}));
|
||||
let rest = rows(&mut h, "panel:MODEL:CREATE");
|
||||
for pinned in ["solid.extrude", "solid.revolve", "solid.hole"] {
|
||||
assert!(!rest.iter().any(|id| *id == format!("dropdown:{pinned}")), "grid {grid}: {pinned} is on the ribbon: {rest:?}");
|
||||
}
|
||||
assert_eq!(rest.len() + 3, all.len(), "grid {grid}: only the ribbon's buttons are left out");
|
||||
// In a window too narrow for the Extrude button, MODEL CREATE's menu lists it again.
|
||||
let narrow = (3..=16).rev().map(|w| w as f32 * 100.0).find(|w| {
|
||||
h.call("ui.resize", json!({"width": w, "height": 1000}));
|
||||
h.frames(4);
|
||||
crate::scenario::handle_at("button:solid.extrude").is_none()
|
||||
});
|
||||
assert!(narrow.is_some(), "grid {grid}: a narrow window has no room for the Extrude button");
|
||||
let panel = ["panel:MODEL:CREATE", "collapsed:MODEL:CREATE"].into_iter().find(|p| crate::scenario::handle_at(p).is_some());
|
||||
let Some(panel) = panel else { panic!("grid {grid}: MODEL CREATE is in the » menu at {narrow:?}") };
|
||||
let folded = rows(&mut h, panel);
|
||||
assert!(folded.iter().any(|id| id == "dropdown:solid.extrude"), "grid {grid}: Extrude, off the ribbon, is listed: {folded:?}");
|
||||
}
|
||||
let box_ = solidcraft_engine::find_command("solid.box").unwrap();
|
||||
assert_eq!(skip_on_ribbon(vec![box_], &["solid.box"]).len(), 1, "a drop-down is never left empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -269,6 +269,8 @@ fn action_button(ui: &mut Ui, label: &str, kind: Action, look: &ActionLook, padd
|
||||
look.primary_ink
|
||||
} else if primary || !live {
|
||||
t.form_dim
|
||||
} else if marked {
|
||||
t.on_selected(t.form_ink, crate::theme::TEXT_CONTRAST)
|
||||
} else {
|
||||
t.form_ink
|
||||
});
|
||||
@@ -403,22 +405,26 @@ pub(crate) fn button_pair(ui: &mut egui::Ui, key: &str, states: [bool; 2], butto
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let label = crate::i18n::tr_label(button.label);
|
||||
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(t.font_label), t.form_ink);
|
||||
let ink = if on { t.on_selected(t.form_ink, crate::theme::TEXT_CONTRAST) } else { t.form_ink };
|
||||
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(t.font_label), ink);
|
||||
let icon_width = if button.icon.is_some() { t.sketch_footer_icon_size + t.space_4 } else { 0.0 };
|
||||
let left = rect.center().x - (galley.size().x + icon_width) / 2.0;
|
||||
let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(rect));
|
||||
if let Some(icon) = button.icon {
|
||||
crate::icons::paint_icon(
|
||||
// A selection darker or lighter than the theme takes that side's artwork.
|
||||
let side = (on && crate::theme::is_dark_fill(t.selected_bg) != crate::theme::is_dark()).then_some(!crate::theme::is_dark());
|
||||
crate::icons::paint_on(
|
||||
&painter,
|
||||
egui::Rect::from_center_size(
|
||||
egui::pos2(left + t.sketch_footer_icon_size / 2.0, rect.center().y),
|
||||
vec2(t.sketch_footer_icon_size, t.sketch_footer_icon_size),
|
||||
),
|
||||
icon,
|
||||
side,
|
||||
f32::from(t.icon.a()) / 255.0,
|
||||
);
|
||||
}
|
||||
painter.galley(egui::pos2(left + icon_width, rect.center().y - galley.size().y / 2.0), galley, t.form_ink);
|
||||
painter.galley(egui::pos2(left + icon_width, rect.center().y - galley.size().y / 2.0), galley, ink);
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), on, label));
|
||||
t.button_bevel(ui, &response, on);
|
||||
pointer_on_hover(ui, &response);
|
||||
@@ -602,7 +608,7 @@ pub(crate) fn compact_button(ui: &mut Ui, glyph: &str, tooltip: &str, enabled: b
|
||||
let ink = if !enabled {
|
||||
t.form_dim.gamma_multiply(0.35)
|
||||
} else if selected {
|
||||
t.form_primary
|
||||
t.accent_on(t.selected_bg)
|
||||
} else {
|
||||
t.form_dim
|
||||
};
|
||||
|
||||
@@ -232,21 +232,37 @@ pub(crate) fn assistant_approval<R>(ui: &mut Ui, title: &str, body: impl FnOnce(
|
||||
#[cfg(any(not(target_arch = "wasm32"), test))]
|
||||
pub(crate) fn assistant_input(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) -> Response {
|
||||
let t = Tokens::get();
|
||||
// The hint is painted here, not by the TextEdit: a multiline edit wraps its hint onto
|
||||
// several rows, and the hint must stay on one row, ending in an ellipsis when it does not fit.
|
||||
let mut response = ui.add(
|
||||
super::fields::text_input(egui::TextEdit::multiline(value))
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.background_color(egui::Color32::TRANSPARENT)
|
||||
.hint_text(hint)
|
||||
.desired_rows(2)
|
||||
.desired_width(f32::INFINITY)
|
||||
.min_size(vec2(0.0, t.control_height + t.space_8))
|
||||
.font(egui::FontId::proportional(t.font_label)),
|
||||
);
|
||||
super::text_menu::menu(ui, &mut response, value, super::text_menu::Field::LINES);
|
||||
if value.is_empty() {
|
||||
let width = (response.rect.width() - 2.0 * t.form_inset).max(0.0);
|
||||
let galley = assistant_hint_galley(ui, hint, width);
|
||||
let pos = egui::pos2(response.rect.left() + t.form_inset, response.rect.center().y - galley.size().y / 2.0);
|
||||
ui.painter().galley(pos, galley, ui.visuals().weak_text_color());
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// The composer hint laid out on one row of at most `width`, cut with an ellipsis.
|
||||
#[cfg(any(not(target_arch = "wasm32"), test))]
|
||||
pub(crate) fn assistant_hint_galley(ui: &Ui, hint: &str, width: f32) -> std::sync::Arc<egui::Galley> {
|
||||
let t = Tokens::get();
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(hint.to_owned(), egui::FontId::proportional(t.font_label), ui.visuals().weak_text_color());
|
||||
job.wrap = egui::text::TextWrapping { max_width: width, max_rows: 1, break_anywhere: true, overflow_character: Some('\u{2026}') };
|
||||
ui.fonts_mut(|f| f.layout_job(job))
|
||||
}
|
||||
|
||||
/// Compact right-aligned approval actions; wrapping grows by control rows, not the remaining card height.
|
||||
#[cfg(any(not(target_arch = "wasm32"), test))]
|
||||
pub(crate) fn assistant_actions<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
|
||||
@@ -258,6 +274,13 @@ pub(crate) fn assistant_actions<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R)
|
||||
)
|
||||
}
|
||||
|
||||
/// Bottom corners follow the window; the top meets the footer rule square.
|
||||
#[cfg(any(not(target_arch = "wasm32"), test))]
|
||||
fn composer_corners(t: &Tokens) -> egui::CornerRadius {
|
||||
let r = t.radius_xl.saturating_sub(1);
|
||||
egui::CornerRadius { nw: 0, ne: 0, sw: r, se: r }
|
||||
}
|
||||
|
||||
/// Composer's rounded field surface with an accent border only while focused.
|
||||
#[cfg(any(not(target_arch = "wasm32"), test))]
|
||||
pub(crate) fn assistant_composer<R>(ui: &mut Ui, focused: bool, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
|
||||
@@ -265,7 +288,7 @@ pub(crate) fn assistant_composer<R>(ui: &mut Ui, focused: bool, add: impl FnOnce
|
||||
egui::Frame::NONE
|
||||
.fill(t.control_fill)
|
||||
.stroke(if focused { egui::Stroke::new(1.0, t.form_primary) } else { t.control_border_stroke(t.control_border) })
|
||||
.corner_radius(t.radius_xl)
|
||||
.corner_radius(composer_corners(&t))
|
||||
.inner_margin(egui::Margin::symmetric(t.space_10 as i8, t.space_8 as i8))
|
||||
.show(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width().max(0.0));
|
||||
@@ -303,8 +326,10 @@ pub(crate) fn assistant_composer_footer<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui
|
||||
let t = Tokens::get();
|
||||
let output = egui::Frame::NONE
|
||||
.fill(t.dialog_footer)
|
||||
.corner_radius(egui::CornerRadius { nw: 0, ne: 0, sw: t.radius_xl.saturating_sub(1), se: t.radius_xl.saturating_sub(1) })
|
||||
.inner_margin(t.space_10)
|
||||
.corner_radius(composer_corners(&t))
|
||||
// No margin: the composer box inside fills this footer edge to edge. The text padding
|
||||
// lives on the box itself, so there is one inset, not two.
|
||||
.inner_margin(0)
|
||||
.show(ui, add);
|
||||
t.secondary_hline(ui.painter(), output.response.rect.x_range(), output.response.rect.top(), t.dialog_rule);
|
||||
output
|
||||
|
||||
@@ -164,7 +164,18 @@ pub(crate) fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], con
|
||||
egui::pos2(text_rect.left() - t.space_6 - t.space_14 / 2.0, segment.center().y),
|
||||
vec2(t.space_14, t.space_14),
|
||||
);
|
||||
crate::icons::paint_ink(ui.painter(), icon_rect, icon, if on && workspace_accent { accent.ink } else { t.form_ink });
|
||||
crate::icons::paint_ink(
|
||||
ui.painter(),
|
||||
icon_rect,
|
||||
icon,
|
||||
if on && workspace_accent {
|
||||
accent.ink
|
||||
} else if on && !primary {
|
||||
t.on_selected(t.form_ink, crate::theme::MARK_CONTRAST)
|
||||
} else {
|
||||
t.form_ink
|
||||
},
|
||||
);
|
||||
}
|
||||
let text_rect = if compact { text_rect.intersect(segment.shrink2(vec2(t.space_4, 0.0))) } else { text_rect };
|
||||
paint_text(
|
||||
@@ -262,6 +273,10 @@ pub(crate) fn segmented_icons(ui: &mut Ui, selected: &mut usize, options: &[Icon
|
||||
if catalog {
|
||||
let icon_rect = egui::Rect::from_center_size(segment.center(), vec2(t.space_14, t.space_14));
|
||||
crate::icons::paint_ink(ui.painter(), icon_rect, option.icon, if on { accent.ink } else { t.form_dim });
|
||||
} else if on && crate::theme::is_dark_fill(t.selected_bg) != crate::theme::is_dark() {
|
||||
// A selection darker or lighter than the theme (SolidCraft 95's navy) takes the
|
||||
// artwork drawn for that side, so the option's line or dash still shows.
|
||||
crate::icons::paint_on(ui.painter(), icon_rect, option.icon, Some(!crate::theme::is_dark()), f32::from(t.icon.a()) / 255.0);
|
||||
} else {
|
||||
crate::icons::paint_icon(ui.painter(), icon_rect, option.icon, f32::from(t.icon.a()) / 255.0);
|
||||
}
|
||||
@@ -662,8 +677,16 @@ pub(crate) fn popup_list(
|
||||
inner.min,
|
||||
egui::pos2((inner.right() - kind_width - t.space_8).max(inner.left()), inner.bottom()),
|
||||
);
|
||||
super::text::paint_text(ui, text_rect, option.text, mono.clone(), if picked { t.form_primary } else { option.color }, false);
|
||||
ui.painter().text(inner.right_center(), egui::Align2::RIGHT_CENTER, kind, kind_font.clone(), t.form_dim);
|
||||
super::text::paint_text(
|
||||
ui,
|
||||
text_rect,
|
||||
option.text,
|
||||
mono.clone(),
|
||||
if picked { t.accent_on(t.selected_bg) } else { option.color },
|
||||
false,
|
||||
);
|
||||
let kind_ink = if picked { t.on_selected(t.form_dim, crate::theme::MARK_CONTRAST) } else { t.form_dim };
|
||||
ui.painter().text(inner.right_center(), egui::Align2::RIGHT_CENTER, kind, kind_font.clone(), kind_ink);
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), picked, option.text));
|
||||
// Tip the full name first: long identifiers still clip inside a capped popup.
|
||||
let tip = if option.detail.is_empty() { option.text.to_owned() } else { format!("{}\n{}", option.text, option.detail) };
|
||||
|
||||
@@ -298,9 +298,9 @@ impl Container {
|
||||
}
|
||||
|
||||
pub fn area(self, id: Id) -> egui::Area {
|
||||
// Persistent workspace cards stay below tool/system windows even when clicked.
|
||||
// Other areas keep egui's normal window layer; popups choose their own order.
|
||||
let order = if self.surface == Surface::Panel { egui::Order::Background } else { egui::Order::Middle };
|
||||
// Workspace cards and viewport status stay below tool/system windows even when
|
||||
// clicked. Other areas keep the normal window layer; popups choose their order.
|
||||
let order = if matches!(self.surface, Surface::Panel | Surface::ViewportStatus) { egui::Order::Background } else { egui::Order::Middle };
|
||||
egui::Area::new(id).enabled(self.input_enabled()).order(order)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,13 @@ pub(crate) fn count_field(ui: &mut Ui, value: &mut String, limits: &Count<'_>) -
|
||||
if enabled && arrow.hovered() {
|
||||
painter.rect_filled(half.shrink(1.0), t.control_radius, t.selected_bg);
|
||||
}
|
||||
let ink = if enabled { t.form_ink } else { t.form_dim };
|
||||
let ink = if !enabled {
|
||||
t.form_dim
|
||||
} else if arrow.hovered() {
|
||||
t.on_selected(t.form_ink, crate::theme::MARK_CONTRAST)
|
||||
} else {
|
||||
t.form_ink
|
||||
};
|
||||
let (c, w, h) = (half.center(), (reserve * 0.18).max(2.0), (half.height() * 0.2).max(2.0));
|
||||
let tip = if delta > 0 { -h } else { h };
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
|
||||
@@ -277,14 +277,14 @@ impl<'a> MenuItem<'a> {
|
||||
let ink = if self.danger {
|
||||
t.form_error
|
||||
} else if hot {
|
||||
t.form_primary
|
||||
t.accent_on(t.accent_soft)
|
||||
} else {
|
||||
t.form_ink
|
||||
};
|
||||
let dim = if self.danger {
|
||||
t.form_error
|
||||
} else if hot {
|
||||
t.form_primary
|
||||
t.accent_on(t.accent_soft)
|
||||
} else {
|
||||
t.form_dim
|
||||
};
|
||||
@@ -299,7 +299,7 @@ impl<'a> MenuItem<'a> {
|
||||
let icon = Rect::from_center_size(pos2(r.left() + t.control_height / 2.0, r.center().y), vec2(t.space_16, t.space_16));
|
||||
if let Some(on) = self.checked {
|
||||
if on {
|
||||
super::checks::paint_check_glyph(&painter, icon, false, t.form_primary);
|
||||
super::checks::paint_check_glyph(&painter, icon, false, if hot { ink } else { t.form_primary });
|
||||
}
|
||||
} else if let Some(color) = self.swatch {
|
||||
painter.rect(icon, t.radius_sm, color, t.control_border_stroke(t.control_border), egui::StrokeKind::Inside);
|
||||
@@ -419,7 +419,7 @@ pub(crate) fn workspace_menu(ui: &mut egui::Ui, items: &[Item], key_row: Option<
|
||||
let badge = ui.painter().layout_no_wrap(
|
||||
badge.to_string(),
|
||||
FontId::proportional(crate::theme::Tokens::get().font_small),
|
||||
if current { t.on_accent } else { accent },
|
||||
if current { crate::theme::readable(&[accent], crate::theme::MARK_CONTRAST, &[t.on_accent, t.tab_on_fill_dark]) } else { accent },
|
||||
);
|
||||
let badge_rect = Rect::from_min_size(pos2(badge_x, y - 8.0), badge.size() + vec2(t.space_12, t.space_3));
|
||||
ui.painter().rect_filled(badge_rect, t.radius_lg, if current { accent } else { soft });
|
||||
|
||||
@@ -104,11 +104,16 @@ pub(crate) fn navigation_fill(t: &Tokens, fill: NavigationFill) -> Option<egui::
|
||||
/// The label and icon colour of a slot. An active slot keeps the accent ink on its selected fill
|
||||
/// where that reads; a theme whose selected fill is as dark as its accent (SolidCraft 95's navy)
|
||||
/// gets the theme's selected-text ink instead, so an active toggle never turns into a blank block.
|
||||
/// A hovered or pressed slot keeps the body ink unless the theme's press colour is as dark as
|
||||
/// it (1-bit's black press), where the selected-text ink takes over too.
|
||||
pub(crate) fn navigation_ink(t: &Tokens, fill: NavigationFill) -> egui::Color32 {
|
||||
if fill != NavigationFill::Active {
|
||||
return t.form_ink;
|
||||
let readable = |fill, ink| crate::theme::readable(&[fill], NAV_ACTIVE_MIN_CONTRAST, &[ink, t.selected_ink]);
|
||||
match fill {
|
||||
NavigationFill::None => t.form_ink,
|
||||
NavigationFill::Hover => readable(t.nav_hover, t.form_ink),
|
||||
NavigationFill::Press => readable(t.nav_press, t.form_ink),
|
||||
NavigationFill::Active => readable(t.selected_bg, t.form_primary),
|
||||
}
|
||||
if crate::theme::contrast_ratio(t.form_primary, t.selected_bg) >= NAV_ACTIVE_MIN_CONTRAST { t.form_primary } else { t.selected_ink }
|
||||
}
|
||||
|
||||
/// WCAG small-text contrast for an active slot's label on its selected fill.
|
||||
@@ -246,4 +251,88 @@ mod active_ink_tests {
|
||||
}
|
||||
assert!(failures.is_empty(), "active navigation slot unreadable:\n{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Every fill the newer UI paints text or an icon on, with the ink it paints there, reads in
|
||||
/// every built-in theme: the navigation bar and its menus, viewport status items, the
|
||||
/// ribbon's drop-downs and workspace menu, the Render stages, toggles and segmented options,
|
||||
/// badges and the sketch status drawn on the view. A theme older than one of these surfaces
|
||||
/// left it with its base palette's colour, or with its accent on an accent-coloured fill
|
||||
/// (SolidCraft 95's navy on navy).
|
||||
#[test]
|
||||
fn newer_ui_text_and_icons_stay_readable_in_every_built_in_theme() {
|
||||
use crate::theme::{MARK_CONTRAST, TEXT_CONTRAST, TabAccent, Tokens, contrast_ratio, readable};
|
||||
use egui::Color32;
|
||||
type Pick = fn(&Tokens) -> Color32;
|
||||
let status: Pick = |t| readable(&[t.viewport_top, t.viewport_bottom], MARK_CONTRAST, &[t.sketch_accent, t.sketch_ink, t.text]);
|
||||
let conflict: Pick = |t| readable(&[t.viewport_top, t.viewport_bottom], MARK_CONTRAST, &[t.error, t.form_error, t.text]);
|
||||
// (where, fill, ink, floor)
|
||||
let pairs: &[(&str, Pick, Pick, f64)] = &[
|
||||
("selected label: navigation menu, Preferences pages, picked rows", |t| t.selected_bg, |t| t.accent_on(t.selected_bg), MARK_CONTRAST),
|
||||
("navigation menu: selected row detail", |t| t.selected_bg, |t| t.on_selected(t.form_dim, MARK_CONTRAST), MARK_CONTRAST),
|
||||
("navigation bar: hovered slot", |t| t.nav_hover, |t| navigation_ink(t, NavigationFill::Hover), TEXT_CONTRAST),
|
||||
("navigation bar: pressed slot", |t| t.nav_press, |t| navigation_ink(t, NavigationFill::Press), TEXT_CONTRAST),
|
||||
("navigation menu: hovered row icon", |t| t.nav_hover, |t| t.form_dim, MARK_CONTRAST),
|
||||
("navigation settings: add slot", |t| t.nav_hover, |t| t.accent_on(t.nav_hover), MARK_CONTRAST),
|
||||
("navigation tooltip", |t| t.nav_tip, |t| t.nav_tip_ink, TEXT_CONTRAST),
|
||||
("viewport status item: name", |t| t.overlay, |t| t.text, TEXT_CONTRAST),
|
||||
("viewport status item: setting", |t| t.overlay, |t| t.text_dim, MARK_CONTRAST),
|
||||
("ribbon: section label", |t| t.toolbar, |t| t.text_dim, MARK_CONTRAST),
|
||||
("ribbon: hovered section label", |t| t.hover, |t| t.text_dim, MARK_CONTRAST),
|
||||
("panels: secondary text", |t| t.panel, |t| t.text_dim, MARK_CONTRAST),
|
||||
("ribbon drop-down: highlighted row", |t| t.accent_soft, |t| t.accent_on(t.accent_soft), MARK_CONTRAST),
|
||||
(
|
||||
"workspace menu: current badge",
|
||||
|t| t.accent,
|
||||
|t| readable(&[t.accent], MARK_CONTRAST, &[t.on_accent, t.tab_on_fill_dark]),
|
||||
MARK_CONTRAST,
|
||||
),
|
||||
(
|
||||
"Render stage bar: current stage number",
|
||||
|t| TabAccent::for_tab(t, "RENDER").color,
|
||||
|t| TabAccent::for_tab(t, "RENDER").on_fill(t),
|
||||
MARK_CONTRAST,
|
||||
),
|
||||
("toggle buttons, keyboard-marked action: on", |t| t.selected_bg, |t| t.on_selected(t.form_ink, TEXT_CONTRAST), TEXT_CONTRAST),
|
||||
("toggle and segmented icons: on", |t| t.selected_bg, |t| t.on_selected(t.form_ink, MARK_CONTRAST), MARK_CONTRAST),
|
||||
("segmented choice: selected label", |t| t.selected_bg, |t| t.selected_ink, TEXT_CONTRAST),
|
||||
("settings and search badges: valid", |t| t.form_success_fill, |t| t.form_success, MARK_CONTRAST),
|
||||
("search badges: invalid", |t| t.form_error_fill, |t| t.form_error, MARK_CONTRAST),
|
||||
("dialogs: success text", |t| t.dialog_body, |t| t.form_success, MARK_CONTRAST),
|
||||
(
|
||||
"title bar: offer pill",
|
||||
|t| t.accent,
|
||||
|t| readable(&[t.accent], MARK_CONTRAST, &[t.app_bar_text, t.on_accent, t.tab_on_fill_dark]),
|
||||
MARK_CONTRAST,
|
||||
),
|
||||
("save mesh: lettered tile", |t| t.chip, |t| readable(&[t.chip], TEXT_CONTRAST, &[t.text, t.on_accent]), MARK_CONTRAST),
|
||||
("sketch status on the view, top", |t| t.viewport_top, status, MARK_CONTRAST),
|
||||
("sketch status on the view, bottom", |t| t.viewport_bottom, status, MARK_CONTRAST),
|
||||
("sketch conflict status on the view, top", |t| t.viewport_top, conflict, MARK_CONTRAST),
|
||||
("sketch conflict status on the view, bottom", |t| t.viewport_bottom, conflict, MARK_CONTRAST),
|
||||
];
|
||||
// A translucent fill is seen over the panel it floats on, translucent ink over its fill.
|
||||
let solid = |c: Color32, under: Color32| {
|
||||
if c.a() == 255 {
|
||||
return c;
|
||||
}
|
||||
let k = f32::from(c.a()) / 255.0;
|
||||
let [r, g, b, _] = c.to_srgba_unmultiplied();
|
||||
let mix = |x: u8, y: u8| (f32::from(x) * k + f32::from(y) * (1.0 - k)).round() as u8;
|
||||
Color32::from_rgb(mix(r, under.r()), mix(g, under.g()), mix(b, under.b()))
|
||||
};
|
||||
let mut themes = vec![("Dark".to_owned(), Tokens::dark()), ("Light".to_owned(), Tokens::light())];
|
||||
themes.extend(crate::theme_files::builtin().iter().map(|theme| (theme.name.clone(), crate::theme_files::resolve(theme))));
|
||||
let mut failures = Vec::new();
|
||||
for (name, t) in &themes {
|
||||
for (place, fill, ink, floor) in pairs {
|
||||
let fill = solid(fill(t), t.panel);
|
||||
let ink = solid(ink(t), fill);
|
||||
let ratio = contrast_ratio(ink, fill);
|
||||
if ratio < *floor {
|
||||
failures.push(format!("{name}: {place}: {ratio:.2}:1 ({ink:?} on {fill:?})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "unreadable in a built-in theme:\n{}", failures.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ impl<'a> NavigationMenuRow<'a> {
|
||||
let t = Tokens::get();
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width().max(0.0), NAV_MENU_ROW), Sense::hover());
|
||||
let response = ui.interact(rect, ui.id().with(self.id), if self.enabled { Sense::click() } else { Sense::hover() });
|
||||
let ink = if self.selected { t.form_primary } else { t.form_ink };
|
||||
let ink = if self.selected { t.accent_on(t.selected_bg) } else { t.form_ink };
|
||||
let bg = if self.selected {
|
||||
Some(t.selected_bg)
|
||||
} else if response.hovered() && self.enabled {
|
||||
@@ -69,7 +69,13 @@ impl<'a> NavigationMenuRow<'a> {
|
||||
Align2::RIGHT_CENTER,
|
||||
detail,
|
||||
FontId::proportional(if self.enabled { t.font_body } else { t.font_small }),
|
||||
if self.enabled { t.form_dim } else { t.nav_dim },
|
||||
if !self.enabled {
|
||||
t.nav_dim
|
||||
} else if self.selected {
|
||||
t.on_selected(t.form_dim, crate::theme::MARK_CONTRAST)
|
||||
} else {
|
||||
t.form_dim
|
||||
},
|
||||
);
|
||||
end = end.min(detail.left() - t.space_8);
|
||||
}
|
||||
@@ -81,7 +87,7 @@ impl<'a> NavigationMenuRow<'a> {
|
||||
ui.painter().galley(pos2(left, rect.center().y - galley.size().y / 2.0), galley, ink);
|
||||
let tail = Rect::from_center_size(pos2(rect.right() - NAV_MENU_INSET - t.space_14 / 2.0, rect.center().y), vec2(t.space_14, t.space_14));
|
||||
if self.selected {
|
||||
super::checks::paint_check_glyph(ui.painter(), tail, false, t.form_primary);
|
||||
super::checks::paint_check_glyph(ui.painter(), tail, false, ink);
|
||||
} else if self.submenu {
|
||||
let arrow =
|
||||
Rect::from_center_size(pos2(rect.right() - NAV_MENU_INSET - NAV_CHEVRON / 2.0, rect.center().y), vec2(NAV_CHEVRON, NAV_CHEVRON));
|
||||
|
||||
@@ -63,7 +63,7 @@ pub(crate) fn navigation_tile(ui: &mut Ui, id: &str, label: &str, icon: &str, dr
|
||||
if add.hovered() {
|
||||
painter.rect_filled(add_rect, t.control_radius.saturating_sub(NAV_BORDER as u8), t.nav_hover);
|
||||
}
|
||||
icons::paint_ink(&painter, Rect::from_center_size(add_rect.center(), vec2(t.space_14, t.space_14)), "plus", t.form_primary);
|
||||
icons::paint_ink(&painter, Rect::from_center_size(add_rect.center(), vec2(t.space_14, t.space_14)), "plus", t.accent_on(t.nav_hover));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
|
||||
add.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::format("navbar.custom.add_slot", &[("slot", label)]))
|
||||
|
||||
@@ -1234,7 +1234,7 @@ fn page_list_sized<S>(
|
||||
if on || response.hovered() || response.has_focus() {
|
||||
ui.painter().rect_filled(rect, t.control_radius, if on { t.selected_bg } else { t.hover });
|
||||
}
|
||||
let ink = if on { t.form_primary } else { t.form_ink };
|
||||
let ink = if on { t.accent_on(t.selected_bg) } else { t.form_ink };
|
||||
let icon =
|
||||
egui::Rect::from_center_size(egui::pos2(rect.left() + t.space_10 + t.space_8, rect.center().y), vec2(t.space_16, t.space_16));
|
||||
crate::icons::paint_ink(ui.painter(), icon, page.icon, if on { ink } else { t.form_dim });
|
||||
|
||||
@@ -186,7 +186,7 @@ impl StageBar {
|
||||
},
|
||||
&(index + 1).to_string(),
|
||||
crate::fonts::semibold(t.font_small),
|
||||
if on { t.on_accent } else { t.text_dim },
|
||||
if on { accent.on_fill(&t) } else { t.text_dim },
|
||||
false,
|
||||
);
|
||||
let text = Rect::from_min_max(pos2(badge.right() + t.space_6 + 1.0, chip.top()), chip.right_bottom() - vec2(t.space_14, 0.0));
|
||||
|
||||
@@ -352,7 +352,7 @@ pub(crate) fn plus_header<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> Opt
|
||||
let response = super::actions::icon_button_colored(ui, rect, "plus", "Add Column", Some(t.form_dim));
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(rect, t.control_radius, t.selected_bg);
|
||||
crate::icons::paint_ink(ui.painter(), rect.shrink(t.form_icon_inset), "plus", t.form_primary);
|
||||
crate::icons::paint_ink(ui.painter(), rect.shrink(t.form_icon_inset), "plus", t.accent_on(t.selected_bg));
|
||||
for x in [rect.left() - t.form_gap_y, rect.right() + t.form_gap_y] {
|
||||
ui.painter().vline(x, rect.y_range(), egui::Stroke::new(t.space_2, t.form_primary));
|
||||
}
|
||||
|
||||
@@ -35,6 +35,13 @@ pub(crate) struct StatusItem {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct StatusPresentation {
|
||||
pub open_key: Option<String>,
|
||||
focus_ids: Vec<Id>,
|
||||
}
|
||||
|
||||
impl StatusPresentation {
|
||||
pub(crate) fn owns_focus(&self, ctx: &egui::Context) -> bool {
|
||||
ctx.memory(|memory| memory.focused().is_some_and(|id| self.focus_ids.contains(&id)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -116,19 +123,24 @@ pub(crate) fn viewport_status_item(ui: &mut Ui, item: &StatusItem, state: &mut S
|
||||
if item.has_key {
|
||||
let open = state.open_key.as_deref() == Some(&item.id);
|
||||
let label = crate::i18n::format("ui.viewport_status_key", &[("name", &item.name)]);
|
||||
if icon_action(ui, item, "key", "chevron", &label, &label, open).clicked() {
|
||||
let response = icon_action(ui, item, "key", "chevron", &label, &label, open);
|
||||
state.focus_ids.push(response.id);
|
||||
if response.clicked() {
|
||||
state.open_key = if open { None } else { Some(item.id.clone()) };
|
||||
}
|
||||
}
|
||||
let label = crate::i18n::format("ui.viewport_status_clear", &[("name", &item.name)]);
|
||||
let hint = crate::i18n::format("ui.viewport_status_clear_hint", &[("name", &item.name)]);
|
||||
if icon_action(ui, item, "close", "close", &label, &hint, false).clicked() {
|
||||
let response = icon_action(ui, item, "close", "close", &label, &hint, false);
|
||||
state.focus_ids.push(response.id);
|
||||
if response.clicked() {
|
||||
action = Some(StatusAction::Clear(item.id.clone()));
|
||||
}
|
||||
});
|
||||
})
|
||||
.response;
|
||||
let focus = ui.interact(response.rect, Id::new(target(item, "item")), egui::Sense::focusable_noninteractive());
|
||||
state.focus_ids.push(focus.id);
|
||||
focus.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), format!("{} {}", item.name, item.setting)));
|
||||
if focus.has_focus() {
|
||||
ui.painter().rect_stroke(response.rect.expand(t.space_3), frame.corner_radius, ui.visuals().selection.stroke, egui::StrokeKind::Outside);
|
||||
@@ -145,6 +157,7 @@ pub(crate) fn viewport_status_row(
|
||||
state: &mut StatusPresentation,
|
||||
mut key_contents: impl FnMut(&mut Ui, &str),
|
||||
) -> Vec<StatusAction> {
|
||||
state.focus_ids.clear();
|
||||
if items.is_empty() {
|
||||
state.open_key = None;
|
||||
return Vec::new();
|
||||
@@ -179,11 +192,40 @@ pub(crate) fn viewport_status_row(
|
||||
scenario::publish_count("viewport_status:count", items.len() as f64);
|
||||
if let Some((anchor, id)) = key_anchor {
|
||||
let card = Container::new(ctx, Surface::Popup);
|
||||
let result = card.area(Id::new("viewport_status_key")).movable(false).fixed_pos(anchor + vec2(0.0, t.space_6)).show(ctx, |ui| {
|
||||
let frame = card.frame(ctx, t.overlay, crate::frame::Side::Left).inner_margin(t.space_12);
|
||||
card.show_frame(ui, frame, free, |ui| key_contents(ui, id));
|
||||
});
|
||||
// The key belongs to viewport chrome, not to the system-window/popup layer.
|
||||
let result =
|
||||
card.area(Id::new("viewport_status_key")).order(egui::Order::Background).movable(false).fixed_pos(anchor + vec2(0.0, t.space_6)).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
let frame = card.frame(ctx, t.overlay, crate::frame::Side::Left).inner_margin(t.space_12);
|
||||
card.show_frame(ui, frame, free, |ui| key_contents(ui, id));
|
||||
},
|
||||
);
|
||||
scenario::publish_target("viewport_status:card", result.response.rect);
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
/// Shared legend content; the caller supplies the existing analysis-token swatches and text.
|
||||
pub(crate) fn analysis_key(ui: &mut Ui, title: &str, along: Option<&str>, rows: &[(egui::Color32, String, bool)]) {
|
||||
let t = Tokens::get();
|
||||
ui.label(RichText::new(title).semibold().size(t.font_label).color(t.text));
|
||||
if let Some(along) = along {
|
||||
ui.label(RichText::new(along).monospace().size(t.font_small).color(t.text_dim));
|
||||
}
|
||||
for (colour, label, hatched) in rows {
|
||||
ui.horizontal(|ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(t.space_12, t.space_12), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, t.radius_sm, *colour);
|
||||
if *hatched {
|
||||
let painter = ui.painter().with_clip_rect(rect);
|
||||
for offset in [-t.space_8, -t.space_4, 0.0, t.space_4, t.space_8] {
|
||||
let from = rect.left_bottom() + vec2(offset, 0.0);
|
||||
painter
|
||||
.line_segment([from, from + vec2(rect.height(), -rect.height())], egui::Stroke::new(t.primary_border(t.text).width, t.text));
|
||||
}
|
||||
}
|
||||
ui.label(RichText::new(label).size(t.font_label).color(t.text));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,6 +889,12 @@ fn poll_install(app: &mut SolveApp) {
|
||||
Err(TryRecvError::Disconnected) => Err("the update stopped".into()),
|
||||
};
|
||||
app.updater.job = None;
|
||||
// Cancel already cleared the job in the UI; ignore a late "cancelled" result so Offer returns
|
||||
// without an error banner (#8908).
|
||||
if matches!(&result, Err(e) if e == "cancelled" || e.contains("cancelled")) {
|
||||
app.updater.installed = None;
|
||||
return;
|
||||
}
|
||||
match &result {
|
||||
Ok(install::Outcome::Installer(program, args)) => {
|
||||
// The installer replaces this program: start it once SolidCraft has closed. On
|
||||
@@ -1084,9 +1090,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
Some("restart") => restart(app),
|
||||
Some("close") => app.updater.open = false,
|
||||
Some("cancel_download") => {
|
||||
if let Some((_, progress)) = &app.updater.job {
|
||||
// Drop the job from the UI at once so Cancel leaves "Downloading" immediately; the
|
||||
// worker still sees the flag and stops at the next feed/checksum/chunk check (#8908).
|
||||
if let Some((_, progress)) = app.updater.job.take() {
|
||||
progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
app.updater.installed = None;
|
||||
}
|
||||
_ if closed => app.updater.open = false,
|
||||
_ => {}
|
||||
|
||||
@@ -172,6 +172,19 @@ pub struct Net {
|
||||
pub feed: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// GET `url` unless Cancel was pressed; check again after the response so a cancel during the
|
||||
/// wait still ends the install without starting the package download (#8908).
|
||||
fn get_unless_cancelled(net: &Net, progress: &Progress, url: &str) -> Result<Vec<u8>, FetchError> {
|
||||
if progress.cancel.load(Ordering::Relaxed) {
|
||||
return Err(FetchError::Failed("cancelled".into()));
|
||||
}
|
||||
let body = (net.get)(url)?;
|
||||
if progress.cancel.load(Ordering::Relaxed) {
|
||||
return Err(FetchError::Failed("cancelled".into()));
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Refresh the feed, then download, verify and apply the newest release for `method`, falling
|
||||
/// back to `release` when the check fails. Runs on a background thread. `staging` holds downloads
|
||||
/// that are not written next to their target (the `.msi`, the `.dmg`).
|
||||
@@ -184,6 +197,11 @@ pub fn run(
|
||||
staging: &Path,
|
||||
progress: &Progress,
|
||||
) -> Result<Outcome, String> {
|
||||
// Honour Cancel before each network wait: feed refresh and checksum fetches used to run
|
||||
// without looking at the flag, so Cancel looked dead until the body download started (#8908).
|
||||
if progress.cancel.load(Ordering::Relaxed) {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
// The release shown may be stale by now: install the newest one the feed names, or the one
|
||||
// shown when the feed can't be read.
|
||||
let (release, fallback) = match super::fetch_latest(net.feed.as_deref(), Some(&net.get)) {
|
||||
@@ -200,7 +218,7 @@ pub fn run(
|
||||
return Err(format!("{} is too large ({} bytes)", asset.name, asset.size));
|
||||
}
|
||||
let sums_url = release.assets.iter().find(|a| a.name == SUMS).map(|a| a.url.clone()).ok_or_else(|| format!("this release has no {SUMS}"))?;
|
||||
let sums = (net.get)(&sums_url).map_err(|e| format!("{SUMS}: {e}"))?;
|
||||
let sums = get_unless_cancelled(net, progress, &sums_url).map_err(|e| format!("{SUMS}: {e}"))?;
|
||||
if let Some(key) = PUBLIC_KEY {
|
||||
let sig_url = release
|
||||
.assets
|
||||
@@ -208,7 +226,7 @@ pub fn run(
|
||||
.find(|a| a.name == SUMS_SIG)
|
||||
.map(|a| a.url.clone())
|
||||
.ok_or_else(|| format!("this release isn't signed (no {SUMS_SIG})"))?;
|
||||
let sig = (net.get)(&sig_url).map_err(|e| format!("{SUMS_SIG}: {e}"))?;
|
||||
let sig = get_unless_cancelled(net, progress, &sig_url).map_err(|e| format!("{SUMS_SIG}: {e}"))?;
|
||||
verify_signature(&sums, &String::from_utf8_lossy(&sig), key, &release.tag)?;
|
||||
}
|
||||
let sums = String::from_utf8_lossy(&sums);
|
||||
|
||||
@@ -129,12 +129,41 @@ fn an_appimage_is_replaced_only_when_it_verifies() {
|
||||
let bare = release_with(&[(name, new.len())]);
|
||||
assert!(run(&bare, &kind, &m, "x86_64", &n, &dir, &Progress::default()).unwrap_err().contains(SUMS));
|
||||
|
||||
// Cancelled: nothing changes.
|
||||
// Cancelled before any network work: nothing changes.
|
||||
let good = format!("{} {name}\n", hex(&new));
|
||||
let n = net(vec![(name.into(), new.clone()), (SUMS.into(), good.clone().into_bytes())]);
|
||||
let p = Progress::default();
|
||||
p.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(run(&r, &kind, &m, "x86_64", &n, &dir, &p).is_err());
|
||||
let err = run(&r, &kind, &m, "x86_64", &n, &dir, &p).unwrap_err();
|
||||
assert!(err.contains("cancelled"), "{err}");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"old version");
|
||||
|
||||
// Cancel during the checksum GET: package download never starts (#8908).
|
||||
let progress = Arc::new(Progress::default());
|
||||
let flag = progress.clone();
|
||||
let downloaded = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let hit = downloaded.clone();
|
||||
let files = Arc::new(vec![(name.into(), new.clone()), (SUMS.into(), good.clone().into_bytes())]);
|
||||
let f2 = files.clone();
|
||||
let find = |files: &[(String, Vec<u8>)], url: &str| files.iter().find(|(n, _)| url == format!("{BASE}{n}")).map(|(_, b)| b.clone());
|
||||
let n = Net {
|
||||
feed: None,
|
||||
get: Arc::new(move |url: &str| {
|
||||
let body = find(&files, url).ok_or_else(|| FetchError::Failed(format!("404 {url}")))?;
|
||||
if url.ends_with(SUMS) {
|
||||
flag.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Ok(body)
|
||||
}),
|
||||
download: Arc::new(move |url: &str, to: &Path, _| {
|
||||
hit.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let body = find(&f2, url).ok_or_else(|| FetchError::Failed(format!("404 {url}")))?;
|
||||
std::fs::write(to, body).map_err(|e| FetchError::Failed(e.to_string()))
|
||||
}),
|
||||
};
|
||||
let err = run(&r, &kind, &m, "x86_64", &n, &dir, &progress).unwrap_err();
|
||||
assert!(err.contains("cancelled"), "{err}");
|
||||
assert!(!downloaded.load(std::sync::atomic::Ordering::Relaxed), "package must not start after Cancel");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"old version");
|
||||
|
||||
// Verified: renamed over the old one, executable, restart offered.
|
||||
|
||||
@@ -750,3 +750,48 @@ fn install_selection_refreshes_the_button_and_progress_before_completion() {
|
||||
assert!(h.app.updater.installed.is_none());
|
||||
tx.send(()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_download_leaves_downloading_immediately() {
|
||||
// Cancel must clear the job from the UI at once so the card leaves "Downloading" without
|
||||
// waiting for a stalled feed or checksum GET (#8908).
|
||||
let dir = scratch();
|
||||
let target = dir.0.join("SolidCraft.AppImage");
|
||||
let name = asset_name(&InstallKind::AppImage(target.clone()), std::env::consts::ARCH, "999.1.0").unwrap();
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.check_updates = false;
|
||||
let mut shown = release();
|
||||
shown.tag = "v999.0.0".into();
|
||||
h.app.updater.release = Some(shown);
|
||||
h.app.updater.kind = Some(InstallKind::AppImage(target));
|
||||
h.app.updater.open = true;
|
||||
let (gate_tx, gate_rx) = std::sync::mpsc::channel();
|
||||
let gate_rx = std::sync::Mutex::new(gate_rx);
|
||||
h.app.services.http_get = Some(Arc::new(move |url| {
|
||||
if url == LATEST_API {
|
||||
return Ok(json!({"tag_name":"v999.1.0", "assets":[
|
||||
{"name": name, "browser_download_url":format!("https://github.com/tangency-labs/solidcraft/releases/download/v999.1.0/{name}")},
|
||||
{"name": install::SUMS, "browser_download_url":"https://github.com/tangency-labs/solidcraft/releases/download/v999.1.0/SHA256SUMS.txt"}
|
||||
]}).to_string().into_bytes());
|
||||
}
|
||||
// Stall checksum GET until the test ends so Cancel has to work before the body download.
|
||||
let _ = gate_rx.lock().unwrap().recv();
|
||||
Ok(format!("{} {name}\n", "a".repeat(64)).into_bytes())
|
||||
}));
|
||||
h.app.services.http_download = Some(Arc::new(|_, _, _| Err(FetchError::Failed("download must not start".into()))));
|
||||
start_install(&mut h.app, &egui::Context::default());
|
||||
assert!(h.app.updater.job.is_some());
|
||||
h.frames(2);
|
||||
let cancel = h.widget("Cancel").expect("Cancel while Downloading");
|
||||
h.call("ui.click", json!({"x": cancel[0], "y": cancel[1]}));
|
||||
assert!(h.app.updater.job.is_none(), "Cancel clears the job immediately");
|
||||
assert!(h.app.updater.installed.is_none());
|
||||
assert!(h.app.updater.open, "Cancel leaves the Update window open on Offer");
|
||||
h.frames(2);
|
||||
assert!(
|
||||
h.widget("Remind me later").is_some() || h.widget("Skip this version").is_some() || h.widget("Update now").is_some(),
|
||||
"Offer returns after Cancel"
|
||||
);
|
||||
assert!(h.widget("Updating to 999.1.0.").is_none());
|
||||
let _ = gate_tx.send(());
|
||||
}
|
||||
|
||||
@@ -2904,7 +2904,9 @@ fn analysis_uniform(app: &SolveApp) -> [f32; 8] {
|
||||
/// While Draft, Accessibility or Curvature Map analysis is on, what each of its colours means and
|
||||
/// the values it stands for, at the bottom left of the viewport (#973). The colours alone don't
|
||||
/// say, and with colour vision loss they can be hard to tell apart.
|
||||
fn analysis_legend(app: &SolveApp, painter: &egui::Painter, rect: Rect) {
|
||||
pub(crate) type AnalysisLegend = (&'static str, Option<String>, Vec<(Color32, String, bool)>);
|
||||
|
||||
pub(crate) fn analysis_legend_data(app: &SolveApp) -> Option<AnalysisLegend> {
|
||||
use solidcraft_engine::SurfaceAnalysis as A;
|
||||
let t = Tokens::get();
|
||||
let tr = crate::i18n::tr_label;
|
||||
@@ -2915,7 +2917,7 @@ fn analysis_legend(app: &SolveApp, painter: &egui::Painter, rect: Rect) {
|
||||
let dir = |v: Vec3| format!("({:.2}, {:.2}, {:.2})", v.x, v.y, v.z);
|
||||
let a = &t.analysis;
|
||||
// (title, the direction it measures along, rows of colour, text, hatched).
|
||||
let (title, along, rows): (&str, Option<String>, Vec<(Color32, String, bool)>) = match app.session.analysis {
|
||||
let (title, along, rows) = match app.session.analysis {
|
||||
Some(A::Draft { pull, angle }) => (
|
||||
tr("Draft Analysis"),
|
||||
Some(format!("{} {}", tr("Pull direction"), dir(pull))),
|
||||
@@ -2940,8 +2942,14 @@ fn analysis_legend(app: &SolveApp, painter: &egui::Painter, rect: Rect) {
|
||||
(col(a.curvature[2]), format!("{} ≤ {}", tr("Radius"), crate::prefs::show_mm(app, radius, 1)), false),
|
||||
],
|
||||
),
|
||||
_ => return,
|
||||
_ => return None,
|
||||
};
|
||||
Some((title, along, rows))
|
||||
}
|
||||
|
||||
fn analysis_legend(app: &SolveApp, painter: &egui::Painter, rect: Rect) {
|
||||
let Some((title, along, rows)) = analysis_legend_data(app) else { return };
|
||||
let t = Tokens::get();
|
||||
let (pad, row) = (8.0, 18.0);
|
||||
let h = pad * 2.0 + 18.0 + along.as_ref().map_or(0.0, |_| row) + row * rows.len() as f32;
|
||||
// Clear Browser and a left History card so the legend is not painted under them (#4515).
|
||||
@@ -4309,7 +4317,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
let overlay_rect = crate::shell::bottom_overlay_rect(app, rect);
|
||||
analysis_legend(app, &painter, overlay_rect);
|
||||
if !app.preferences.viewport_analysis_status {
|
||||
analysis_legend(app, &painter, overlay_rect);
|
||||
}
|
||||
crate::frame_rate::paint(app, ui, &painter, overlay_rect);
|
||||
|
||||
// ---- interaction ----
|
||||
@@ -4526,6 +4536,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
.collect();
|
||||
let _ = app.run("select.set", json!({ "items": items, "add": add }));
|
||||
}
|
||||
// Double-click a curve while a Mirror/Move/Copy/Scale/pattern Entities input is open:
|
||||
// add the whole connected chain to that input (#8390).
|
||||
if resp.double_clicked() && !held && !dim_edit && app.tool.is_none() && app.dialog.is_some() {
|
||||
let _ = crate::sketch_modify_ui::double_click_entities(app);
|
||||
}
|
||||
// Double-click an edge: select the edges that continue it smoothly (the loop).
|
||||
if resp.double_clicked()
|
||||
&& !held
|
||||
@@ -4875,6 +4890,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
axis_view(app, ui, rect);
|
||||
crate::view_angles::show(app, ui, rect);
|
||||
crate::navigation_bar_view::show(app, ui, rect);
|
||||
crate::viewport_status::show(app, ui.ctx(), rect);
|
||||
crate::viewport_status_fixture::show(app, ui.ctx(), rect);
|
||||
// The notices and the agent chip first, so the 3D mouse picture can keep clear of them.
|
||||
let toasts = transient_messages(app, ui, rect);
|
||||
@@ -5933,7 +5949,10 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
crate::i18n::format("sketch.degrees_of_freedom", &[("count", &ss.report.dof.to_string())])
|
||||
};
|
||||
let msg = format!("{} — {status}", crate::i18n::tr_name(&ss.name));
|
||||
let c = if ss.report.ok() { v.sketch_accent } else { v.error };
|
||||
// Straight on the view's gradient: a theme whose sketch or error colour is close to its
|
||||
// background (SolidCraft 95's green and red on teal) gets the view's text colour.
|
||||
let inks = if ss.report.ok() { [v.sketch_accent, v.sketch_ink, v.text] } else { [v.error, v.form_error, v.text] };
|
||||
let c = crate::theme::readable(&[v.viewport_top, v.viewport_bottom], crate::theme::MARK_CONTRAST, &inks);
|
||||
painter.text(pos2(r.left() + 12.0, r.top() + 12.0), Align2::LEFT_TOP, msg, FontId::proportional(t.font_button), c);
|
||||
}
|
||||
}
|
||||
@@ -9249,6 +9268,8 @@ mod tests {
|
||||
// #973: Draft, Accessibility and Curvature Map say what their colours mean; Zebra and no
|
||||
// analysis draw no legend.
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
// Keep the existing corner legend available through the HUD compatibility setting.
|
||||
h.app.preferences.viewport_analysis_status = false;
|
||||
h.app.home.open = false;
|
||||
h.app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap();
|
||||
let legend = |h: &mut crate::scenario::Harness| {
|
||||
|
||||
@@ -680,6 +680,8 @@ fn toast_buttons_group_by_purpose_and_wrap_as_groups() {
|
||||
#[test]
|
||||
fn bottom_readouts_clear_floating_history_on_the_first_frame() {
|
||||
let mut h = Harness::new();
|
||||
// The corner legend remains available through the analysis HUD compatibility setting.
|
||||
h.app.preferences.viewport_analysis_status = false;
|
||||
h.app.home.open = false;
|
||||
h.app.ui.layout = LayoutMode::Floating;
|
||||
h.app.preferences.timeline_left = false;
|
||||
@@ -738,6 +740,8 @@ fn continuity_legend_clears_floating_history() {
|
||||
#[test]
|
||||
fn left_history_sidebar_leaves_bottom_readouts_at_the_bottom() {
|
||||
let mut h = Harness::new();
|
||||
// The corner legend remains available through the analysis HUD compatibility setting.
|
||||
h.app.preferences.viewport_analysis_status = false;
|
||||
h.app.home.open = false;
|
||||
h.app.ui.layout = LayoutMode::Floating;
|
||||
h.app.preferences.timeline_left = true;
|
||||
@@ -1141,3 +1145,393 @@ fn selected_sketch_point_aliases_respect_owner_and_visibility() {
|
||||
"selection does not reveal hidden points; the origin stays visible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_analysis_status_uses_session_settings_and_declared_clear_without_history() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.session.mark_saved();
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
|
||||
for (command, params, expected, key) in [
|
||||
("inspect.zebra", json!({"stripes":14.5}), "14.5 stripes", false),
|
||||
("inspect.draft", json!({"pull":[0,0,1],"angle":7.5}), "Z · 7.5°", true),
|
||||
("inspect.environment_map", json!({}), "", false),
|
||||
("inspect.accessibility", json!({"direction":[0,0,1]}), "from Z", true),
|
||||
("inspect.curvature_map", json!({"radius":12}), "R 12.00 mm", true),
|
||||
("inspect.continuity", json!({"edges":[[5,0,0]],"samples":5}), "1 edges", false),
|
||||
] {
|
||||
h.app.run(command, params).unwrap();
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
let item = crate::viewport_status::presentation(&h.app, &effect).unwrap();
|
||||
assert_eq!(item.setting, expected);
|
||||
assert_eq!(item.has_key, key);
|
||||
assert_eq!(item.name, crate::toolbar::persistent_button_name(&h.app, solidcraft_engine::find_command(command).unwrap()));
|
||||
h.frames(4);
|
||||
assert!(crate::scenario::rect_of(&format!("viewport_status:{}:item", item.id)).is_some());
|
||||
if key {
|
||||
assert!(crate::scenario::rect_of("analysis:legend").is_none());
|
||||
h.step(&json!({"click":{"handle":format!("viewport_status:{}:key",item.id)}})).unwrap();
|
||||
h.frames(3);
|
||||
let pill = crate::scenario::rect_of(&format!("viewport_status:{}:item", item.id)).unwrap();
|
||||
let card = crate::scenario::rect_of("viewport_status:card").unwrap();
|
||||
assert!(card.top() > pill.bottom());
|
||||
assert!((card.left() - pill.left()).abs() < 1.0);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.viewport_status.open_key.is_none());
|
||||
assert_eq!(h.app.session.status_items()[0].command, command);
|
||||
}
|
||||
h.step(&json!({"click":{"handle":format!("viewport_status:{}:close",item.id)}})).unwrap();
|
||||
assert!(h.app.session.status_items().is_empty());
|
||||
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
|
||||
assert!(!h.app.session.is_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_preference_restores_corner_legend_and_old_escape() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":false}));
|
||||
h.frames(4);
|
||||
assert!(crate::scenario::rect_of("analysis:legend").is_some());
|
||||
assert!(crate::scenario::rect_of("viewport_status:row").is_none());
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.analysis.is_some());
|
||||
assert!(h.app.preferences.persistent_ribbon_pressed);
|
||||
assert!(h.app.session.single_surface_analysis());
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":true}));
|
||||
h.frames(4);
|
||||
assert!(crate::scenario::rect_of("analysis:legend").is_none());
|
||||
assert!(crate::scenario::rect_of("viewport_status:row").is_some());
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.analysis.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_formats_units_and_truthful_non_axis_directions() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("inspect.draft", json!({"pull":[1,1,0],"angle":2})).unwrap();
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
assert_eq!(crate::viewport_status::presentation(&h.app, &effect).unwrap().setting, "(0.71, 0.71, 0.00) · 2°");
|
||||
h.app.run("inspect.accessibility", json!({"direction":[0,0,-1]})).unwrap();
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
assert_eq!(crate::viewport_status::presentation(&h.app, &effect).unwrap().setting, "from −Z");
|
||||
h.app.run("inspect.curvature_map", json!({"radius":25.4})).unwrap();
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
for (units, expected) in [("mm", "R 25.40 mm"), ("in", "R 1.000 in")] {
|
||||
h.app.session.doc_mut().units = units.into();
|
||||
assert_eq!(crate::viewport_status::presentation(&h.app, &effect).unwrap().setting, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_escape_ends_live_tool_before_effect_and_never_hidden_section() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.start("inspect.continuity");
|
||||
assert!(h.app.tool.is_some());
|
||||
assert!(!h.app.session.continuity.is_empty());
|
||||
h.frames(3);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.tool.is_none());
|
||||
assert!(!h.app.session.continuity.is_empty(), "first Escape only puts down the live tool");
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.continuity.is_empty());
|
||||
h.app.run("inspect.zebra", json!({})).unwrap();
|
||||
h.app.run("inspect.section", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
h.app.run("inspect.section", json!({"plane":"XZ"})).unwrap();
|
||||
h.frames(3);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.analysis.is_none());
|
||||
assert!(h.app.session.section.is_some(), "a later-issue effect with no presented item is ineligible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_escape_yields_to_search_even_without_text_field_focus() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
h.frames(3);
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
let item = crate::viewport_status::presentation(&h.app, &effect).unwrap();
|
||||
h.step(&json!({"click":{"handle":format!("viewport_status:{}:key",item.id)}})).unwrap();
|
||||
assert!(h.app.viewport_status.open_key.is_some());
|
||||
h.app.start("ui.palette");
|
||||
h.frames(3);
|
||||
h.ctx.memory_mut(|memory| {
|
||||
if let Some(id) = memory.focused() {
|
||||
memory.surrender_focus(id);
|
||||
}
|
||||
});
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(!h.app.ui.palette_open, "Search commands owns the first Escape");
|
||||
assert!(h.app.viewport_status.open_key.is_some());
|
||||
assert!(h.app.session.analysis.is_some());
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.viewport_status.open_key.is_none());
|
||||
assert!(h.app.session.analysis.is_some(), "the following Escape closes only the analysis key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_ignores_invalid_layout_bounds_without_ending_the_effect() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
let before = (h.app.session.status_items(), h.app.session.revision);
|
||||
for bounds in [Rect::NOTHING, Rect::from_min_max(pos2(165.0, 165.0), pos2(1.0, 1.0)), Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(10.0, 10.0))] {
|
||||
crate::viewport_status::show(&mut h.app, &h.ctx, bounds);
|
||||
assert_eq!((h.app.session.status_items(), h.app.session.revision), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_preferences_take_first_escape_and_release_the_next_frame() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("inspect.zebra", json!({})).unwrap();
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
h.ctx.memory_mut(|memory| {
|
||||
if let Some(id) = memory.focused() {
|
||||
memory.surrender_focus(id);
|
||||
}
|
||||
});
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(!h.app.prefs_window.open);
|
||||
assert!(h.app.session.analysis.is_some(), "Preferences owns the first Escape even without text focus");
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.analysis.is_none(), "the next idle frame may end the effect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_escape_owner_survives_egui_layout_repasses() {
|
||||
for owner in ["preferences", "command", "tool", "card", "search"] {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
match owner {
|
||||
"preferences" => {
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
}
|
||||
"command" => {
|
||||
h.app.start("solid.fillet");
|
||||
assert!(h.app.dialog.is_some());
|
||||
}
|
||||
"tool" => {
|
||||
h.app.start("inspect.continuity");
|
||||
assert!(h.app.tool.is_some());
|
||||
}
|
||||
"search" => {
|
||||
h.app.start("ui.palette");
|
||||
}
|
||||
_ => {
|
||||
h.frames(3);
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
let item = crate::viewport_status::presentation(&h.app, &effect).unwrap();
|
||||
h.step(&json!({"click":{"handle":format!("viewport_status:{}:key", item.id)}})).unwrap();
|
||||
assert!(h.app.viewport_status.open_key.is_some());
|
||||
}
|
||||
}
|
||||
h.frames(3);
|
||||
h.ctx.memory_mut(|memory| {
|
||||
if let Some(id) = memory.focused() {
|
||||
memory.surrender_focus(id);
|
||||
}
|
||||
});
|
||||
let before = h.app.session.status_items();
|
||||
let mut passes = 0;
|
||||
// One tap, like ui.key, rather than a held Escape that makes the next press a repeat.
|
||||
let mut escape = crate::test_support::key(egui::Key::Escape);
|
||||
escape.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
|
||||
let _output = h.ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(h.ctx.content_rect()),
|
||||
time: Some(h.ctx.input(|input| input.time) + 1.0 / 60.0),
|
||||
focused: true,
|
||||
events: escape,
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
passes += 1;
|
||||
h.app.logic(ui.ctx());
|
||||
h.app.ui(ui);
|
||||
if ui.ctx().current_pass_index() == 0 {
|
||||
ui.ctx().request_discard("check Escape ownership over layout passes");
|
||||
}
|
||||
},
|
||||
);
|
||||
assert!(passes >= 2, "the frame must actually repeat its layout");
|
||||
assert_eq!(h.app.session.status_items(), before, "{owner} owns one Escape over every pass");
|
||||
assert!(!h.app.prefs_window.open && h.app.dialog.is_none() && h.app.tool.is_none() && !h.app.ui.palette_open);
|
||||
assert!(h.app.viewport_status.open_key.is_none());
|
||||
h.frame();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.status_items().is_empty(), "{owner} releases the following idle Escape");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_row_and_key_stay_below_preferences_and_cannot_steal_its_click() {
|
||||
for window_first in [false, true] {
|
||||
let mut h = Harness::new();
|
||||
if window_first {
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
}
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
h.frames(3);
|
||||
let effect = h.app.session.status_items().remove(0);
|
||||
let item = crate::viewport_status::presentation(&h.app, &effect).unwrap();
|
||||
if !window_first {
|
||||
h.step(&json!({"click":{"handle":format!("viewport_status:{}:key", item.id)}})).unwrap();
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
}
|
||||
h.frames(3);
|
||||
let window = crate::scenario::rect_of("prefs:window").unwrap();
|
||||
let mut targets = vec![format!("viewport_status:{}:close", item.id)];
|
||||
if !window_first {
|
||||
targets.push("viewport_status:card".into());
|
||||
}
|
||||
for target in targets {
|
||||
let at = crate::scenario::rect_of(&target).unwrap().center();
|
||||
assert!(window.contains(at), "the real Preferences window must overlap {target}");
|
||||
let layer = h.ctx.layer_id_at(at).unwrap();
|
||||
assert_eq!(layer.order, egui::Order::Middle, "Preferences stays above {target}");
|
||||
assert_ne!(layer.id, egui::Id::new("viewport_status_row"));
|
||||
assert_ne!(layer.id, egui::Id::new("viewport_status_key"));
|
||||
}
|
||||
let at = crate::scenario::handle_at(&format!("viewport_status:{}:close", item.id)).unwrap();
|
||||
h.call("ui.click", json!({"x":at.x,"y":at.y}));
|
||||
assert!(h.app.prefs_window.open);
|
||||
assert!(h.app.session.analysis.is_some(), "an occluded HUD close never receives the window's click");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_focused_dialog_and_real_text_popup_preserve_owner_priority_in_both_policies() {
|
||||
for enabled in [false, true] {
|
||||
for popup in [false, true] {
|
||||
let mut h = Harness::new();
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":enabled}));
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
h.app.start("solid.fillet");
|
||||
h.frames(3);
|
||||
let at = crate::scenario::handle_at("dialog:Radius").unwrap();
|
||||
h.call("ui.click", json!({"x":at.x,"y":at.y}));
|
||||
assert!(h.ctx.memory(|memory| memory.focused().is_some()), "the real radius editor has focus");
|
||||
if popup {
|
||||
h.call("ui.click", json!({"x":at.x,"y":at.y,"button":"right"}));
|
||||
assert!(egui::Popup::is_any_open(&h.ctx), "a real editor context menu is open");
|
||||
let mut reached_guard = false;
|
||||
let mut tap = crate::test_support::key(egui::Key::Escape);
|
||||
tap.push(egui::Event::Key {
|
||||
key: egui::Key::Escape,
|
||||
physical_key: None,
|
||||
pressed: false,
|
||||
repeat: false,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
let _output = h.ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(h.ctx.content_rect()),
|
||||
time: Some(h.ctx.input(|input| input.time) + 1.0 / 60.0),
|
||||
focused: true,
|
||||
events: tap,
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
let first = ui.ctx().current_pass_index() == 0;
|
||||
if first {
|
||||
assert!(egui::Popup::is_any_open(ui.ctx()), "the surviving popup predicate reaches the global guard");
|
||||
reached_guard = true;
|
||||
}
|
||||
h.app.logic(ui.ctx());
|
||||
if first {
|
||||
assert!(h.app.dialog.is_some());
|
||||
assert!(!h.app.esc_handled, "routing first refusal is not completed cancellation");
|
||||
assert!(ui.ctx().input(|input| input.key_pressed(egui::Key::Escape)), "the real popup still receives its Escape");
|
||||
}
|
||||
h.app.ui(ui);
|
||||
},
|
||||
);
|
||||
assert!(reached_guard);
|
||||
assert!(!egui::Popup::is_any_open(&h.ctx));
|
||||
assert!(h.app.dialog.is_some(), "the first Escape closes only the editor's menu");
|
||||
assert!(h.app.session.analysis.is_some());
|
||||
}
|
||||
// Plain TextEdit may blur at begin-pass: this asserts behavior, not guard capture.
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "the dialog's own Escape still cancels in policy {enabled}");
|
||||
assert!(h.app.session.analysis.is_some());
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert_eq!(h.app.session.analysis.is_none(), enabled, "the idle Escape follows the selected policy");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn analysis_escape_relayout_frame(h: &mut Harness, mut first_pass: impl FnMut(&crate::SolveApp)) {
|
||||
let ctx = h.ctx.clone();
|
||||
let mut tap = crate::test_support::key(egui::Key::Escape);
|
||||
tap.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
|
||||
let mut passes = 0;
|
||||
let _output = ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(ctx.content_rect()),
|
||||
time: Some(ctx.input(|input| input.time) + 1.0 / 60.0),
|
||||
focused: true,
|
||||
events: tap,
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
passes += 1;
|
||||
h.app.logic(ui.ctx());
|
||||
h.app.ui(ui);
|
||||
if ui.ctx().current_pass_index() == 0 {
|
||||
first_pass(&h.app);
|
||||
ui.ctx().request_discard("check restored policy during the same Escape frame");
|
||||
}
|
||||
},
|
||||
);
|
||||
assert!(passes >= 2, "the policy transition must actually cross a layout re-pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_preferences_cancel_can_restore_either_policy_without_reusing_escape() {
|
||||
for saved in [false, true] {
|
||||
let mut h = Harness::new();
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":saved}));
|
||||
h.app.run("inspect.zebra", json!({})).unwrap();
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":!saved}));
|
||||
analysis_escape_relayout_frame(&mut h, |app| {
|
||||
assert!(!app.prefs_window.open);
|
||||
assert_eq!(app.preferences.viewport_analysis_status, saved, "Cancel restores policy in pass one");
|
||||
assert!(app.session.analysis.is_some());
|
||||
});
|
||||
assert_eq!(h.app.preferences.viewport_analysis_status, saved);
|
||||
assert!(h.app.session.analysis.is_some(), "Cancel owns this Escape through pass two while restoring policy {saved}");
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert_eq!(h.app.session.analysis.is_none(), saved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_status_preferences_restore_false_keeps_underlying_dialog_through_the_owned_frame() {
|
||||
let mut h = Harness::new();
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":false}));
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.run("inspect.draft", json!({})).unwrap();
|
||||
h.app.start("solid.fillet");
|
||||
assert!(h.app.dialog.is_some());
|
||||
h.call("ui.prefs", json!({"open":"Interface"}));
|
||||
h.call("ui.prefs", json!({"viewport_analysis_status":true}));
|
||||
analysis_escape_relayout_frame(&mut h, |app| {
|
||||
assert!(!app.prefs_window.open);
|
||||
assert!(!app.preferences.viewport_analysis_status, "Cancel restores false before pass two");
|
||||
assert!(app.dialog.is_some(), "Preferences shields the existing command dialog in pass one");
|
||||
});
|
||||
assert!(h.app.dialog.is_some(), "the same Escape must not cancel the underlying dialog in pass two");
|
||||
assert!(h.app.session.analysis.is_some());
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "the next physical Escape belongs to the command dialog");
|
||||
assert!(h.app.session.analysis.is_some());
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.session.analysis.is_some(), "the restored legacy policy keeps shading on idle Escape");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Thin presentation adapter: persistent effects and activation order belong to the session.
|
||||
use crate::{
|
||||
SolveApp,
|
||||
ui_kit::{self, StatusAction, StatusItem, StatusKind},
|
||||
};
|
||||
use solidcraft_engine::status_items::{StatusId, StatusItem as Effect, StatusSetting};
|
||||
|
||||
const PRESENTED: &[StatusId] = &[StatusId::SurfaceAnalysis, StatusId::Continuity];
|
||||
|
||||
fn identity(effect: &Effect) -> String {
|
||||
format!("{:?}:{}", effect.id, effect.command)
|
||||
}
|
||||
|
||||
fn direction(value: [f64; 3]) -> String {
|
||||
for (axis, vector) in [("X", [1.0, 0.0, 0.0]), ("Y", [0.0, 1.0, 0.0]), ("Z", [0.0, 0.0, 1.0])] {
|
||||
if value.iter().zip(vector).all(|(a, b)| (*a - b).abs() < 1e-8) {
|
||||
return axis.into();
|
||||
}
|
||||
if value.iter().zip(vector).all(|(a, b)| (*a + b).abs() < 1e-8) {
|
||||
return format!("−{axis}");
|
||||
}
|
||||
}
|
||||
let [x, y, z] = value;
|
||||
format!("({x:.2}, {y:.2}, {z:.2})")
|
||||
}
|
||||
|
||||
pub(crate) fn presentation(app: &SolveApp, effect: &Effect) -> Option<StatusItem> {
|
||||
if !PRESENTED.contains(&effect.id) {
|
||||
return None;
|
||||
}
|
||||
let command = solidcraft_engine::find_command(&effect.command)?;
|
||||
let setting = match effect.setting {
|
||||
StatusSetting::Zebra { stripes } => crate::i18n::format("ui.viewport_status_stripes", &[("count", &stripes.to_string())]),
|
||||
StatusSetting::Draft { pull, angle_deg } => format!("{} · {angle_deg}°", direction(pull)),
|
||||
StatusSetting::Environment => String::new(),
|
||||
StatusSetting::Accessibility { direction: from } => crate::i18n::format("ui.viewport_status_from", &[("direction", &direction(from))]),
|
||||
StatusSetting::Curvature { radius_mm } => format!("R {}", crate::prefs::show_mm(app, radius_mm, 1)),
|
||||
StatusSetting::Continuity { edge_count } => crate::i18n::format("ui.viewport_status_edges", &[("count", &edge_count.to_string())]),
|
||||
StatusSetting::Section { .. } => return None,
|
||||
};
|
||||
Some(StatusItem {
|
||||
id: identity(effect),
|
||||
kind: StatusKind::Mode,
|
||||
name: crate::toolbar::persistent_button_name(app, command),
|
||||
setting,
|
||||
has_key: effect.has_key,
|
||||
editable: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn clear(app: &mut SolveApp, effect: &Effect) {
|
||||
let _ = app.run(&effect.clear.command, serde_json::json!({"clear": effect.clear.params.clear}));
|
||||
}
|
||||
|
||||
pub(crate) fn has_effect(app: &SolveApp) -> bool {
|
||||
app.session.newest_status_item(PRESENTED).is_some()
|
||||
}
|
||||
|
||||
/// Called only after existing Escape owners have yielded. A repeat cannot cascade clears.
|
||||
pub(crate) fn escape(app: &mut SolveApp, repeat: bool) -> bool {
|
||||
if repeat {
|
||||
return app.viewport_status.open_key.is_some() || has_effect(app);
|
||||
}
|
||||
if let Some(open) = app.viewport_status.open_key.take()
|
||||
&& app.session.status_items().iter().any(|effect| effect.has_key && identity(effect) == open)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Some(effect) = app.session.newest_status_item(PRESENTED) {
|
||||
clear(app, &effect);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut SolveApp, ctx: &egui::Context, viewport: egui::Rect) {
|
||||
if !app.preferences.viewport_analysis_status {
|
||||
app.viewport_status.open_key = None;
|
||||
return;
|
||||
}
|
||||
let effects = app.session.status_items();
|
||||
let items: Vec<_> = effects.iter().filter_map(|effect| presentation(app, effect)).collect();
|
||||
if items.is_empty() {
|
||||
app.viewport_status = Default::default();
|
||||
return;
|
||||
}
|
||||
let content = crate::shell::content_rect(app, viewport);
|
||||
if !content.is_finite() || !content.is_positive() {
|
||||
return;
|
||||
}
|
||||
let mut free = crate::shell::workspace_content(app, content);
|
||||
for card in &app.shell.cards {
|
||||
free.min.y = free.top().max(card.bottom()).min(free.bottom());
|
||||
}
|
||||
if let Some(layout) = &app.viewport.layout {
|
||||
free.max.x = free.right().min(layout.axis_view.left()).max(free.left());
|
||||
}
|
||||
if !free.is_finite() || !free.is_positive() {
|
||||
return;
|
||||
}
|
||||
let legend = crate::viewport::analysis_legend_data(app);
|
||||
let actions = ui_kit::viewport_status_row(ctx, free, &items, &mut app.viewport_status, |ui, _| {
|
||||
if let Some((title, along, rows)) = &legend {
|
||||
ui_kit::analysis_key(ui, title, along.as_deref(), rows);
|
||||
}
|
||||
});
|
||||
for action in actions {
|
||||
if let StatusAction::Clear(id) = action
|
||||
&& let Some(effect) = effects.iter().find(|effect| identity(effect) == id)
|
||||
{
|
||||
clear(app, effect);
|
||||
app.viewport_status.open_key = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,13 @@ pub fn grouped_commands<'a>(tab: &str, panel: &str, commands: &[&'a CommandSpec]
|
||||
out
|
||||
}
|
||||
|
||||
/// The ids of a panel's commands that are in none of its groups: [`grouped_commands`] puts them
|
||||
/// last, without a heading.
|
||||
pub fn ungrouped(tab: &str, panel: &str, commands: &[&CommandSpec]) -> Vec<&'static str> {
|
||||
let groups = groups(tab, panel);
|
||||
commands.iter().map(|c| c.id).filter(|id| !groups.iter().any(|g| g.ids.contains(id))).collect()
|
||||
}
|
||||
|
||||
pub struct Section {
|
||||
pub tab: &'static str,
|
||||
pub panel: &'static str,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[
|
||||
{"note": "#8390: one click inside a closed profile selects its whole boundary for the shared Entities input; a double-click selects a curve's chain."},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}}},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[-10, -40], [-10, 40]]}}},
|
||||
{"expect": {"errors": 0, "sketch_curves": {"own": 5}}},
|
||||
|
||||
{"note": "Open Mirror; a single click inside the rectangle selects the whole profile."},
|
||||
{"start": "sketch.mirror"},
|
||||
{"expect": {"dialog": "SketchModify(Mirror"}},
|
||||
{"click": {"sketch": [20, 15]}},
|
||||
{"expect": {"dialog": "SketchModify(Mirror", "errors": 0}},
|
||||
|
||||
{"note": "Pick the line as the mirror line and OK: all four rectangle sides reflect (4 -> 8, plus the line)."},
|
||||
{"click": {"handle": "dialog:Mirror line"}},
|
||||
{"click": {"sketch": [-10, 0]}},
|
||||
{"click": {"handle": "dialog:OK"}},
|
||||
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 9}}}
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{"note": "A mirror line that is itself among the entities (a chain or profile picked in one action) is not rejected: the command leaves the line in place and mirrors the rest."},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}}},
|
||||
{"expect": {"errors": 0, "sketch_curves": {"own": 4}}},
|
||||
|
||||
{"note": "One click inside the rectangle selects the whole profile (including its left edge)."},
|
||||
{"start": "sketch.mirror"},
|
||||
{"click": {"sketch": [20, 15]}},
|
||||
{"expect": {"dialog": "SketchModify(Mirror"}},
|
||||
|
||||
{"note": "Pick the rectangle's own left edge as the mirror line and OK: the left edge stays put and the other three sides reflect (4 -> 7)."},
|
||||
{"click": {"handle": "dialog:Mirror line"}},
|
||||
{"click": {"sketch": [0, 15]}},
|
||||
{"click": {"handle": "dialog:OK"}},
|
||||
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 7}}}
|
||||
]
|
||||
+2
-1
@@ -19,7 +19,8 @@ versions. Two preferences opt into holding the model:
|
||||
While editing a sketch, the cap pans and zooms without changing the sketch's initial view. After you
|
||||
adjust the view with the navigation cube, the cap can rotate it too. The Navigation
|
||||
page in Preferences has an on/off switch, a speed control and, for each of the cap's six axes, an
|
||||
invert switch and a speed for the 3D mouse. The web app reads
|
||||
invert switch and a speed for the 3D mouse. The cap's zoom direction comes from its own invert
|
||||
switch; **Reverse zoom direction** under Wheel and trackpad reverses the wheel only (#8993). The web app reads
|
||||
it too, through the browser; Firefox needs a 3DxWare setting first (see
|
||||
[Getting started › 3D mouse in the browser](book/src/guide/getting-started.md#3d-mouse-in-the-browser)).
|
||||
|
||||
|
||||
@@ -189,6 +189,17 @@ it sits on the left or under the browser and is wide enough for names. They are
|
||||
rows: the same eyes, folds and right-click menus, so one list is enough for single-part work. The
|
||||
History at the bottom is unchanged.
|
||||
|
||||
Interface › **Menus** › **Large icons** (the default is **List**) shows a section's
|
||||
command menu as rows of large icons instead of a list of names. Point at an icon for its name,
|
||||
shortcut and help; the arrow keys move across and between rows. Rest the pointer on an icon for
|
||||
two seconds and a pin in its corner can be clicked to pin it to the ribbon or remove it (a
|
||||
quicker click anywhere on the icon starts the command). Right-clicking the icon, or dragging it
|
||||
onto the ribbon, also pins it; a pinned command always shows its pin.
|
||||
|
||||
Interface › **Leave pinned commands out of the drop-down menus** (off by default) lists in a
|
||||
section's menu only the commands that are not already buttons on the ribbon. When the window is
|
||||
too narrow for a section's buttons, its menu lists them again.
|
||||
|
||||
### Colour themes
|
||||
|
||||
Interface › **Theme** lists Dark, Light and System (follows the operating system), the built-in
|
||||
|
||||
Reference in New Issue
Block a user