diff --git a/assets/icons/add_scale.svg b/assets/icons/add_scale.svg
index 8524e711a..8e81f2e38 100644
--- a/assets/icons/add_scale.svg
+++ b/assets/icons/add_scale.svg
@@ -1,4 +1,4 @@
diff --git a/assets/icons/arc/arc.svg b/assets/icons/arc/arc.svg
index f8df6193e..c02a74997 100644
--- a/assets/icons/arc/arc.svg
+++ b/assets/icons/arc/arc.svg
@@ -1,5 +1,5 @@
diff --git a/assets/icons/arc/arc_3p.svg b/assets/icons/arc/arc_3p.svg
index dc7c0671c..02bda1efc 100644
--- a/assets/icons/arc/arc_3p.svg
+++ b/assets/icons/arc/arc_3p.svg
@@ -1,7 +1,7 @@
diff --git a/assets/icons/arc/arc_cont.svg b/assets/icons/arc/arc_cont.svg
index de43fb9a3..3ed911bff 100644
--- a/assets/icons/arc/arc_cont.svg
+++ b/assets/icons/arc/arc_cont.svg
@@ -2,6 +2,6 @@
-
-
+
+
diff --git a/assets/icons/arc/arc_csa.svg b/assets/icons/arc/arc_csa.svg
index 70a3aa50c..6948872c0 100644
--- a/assets/icons/arc/arc_csa.svg
+++ b/assets/icons/arc/arc_csa.svg
@@ -1,10 +1,10 @@
diff --git a/assets/icons/arc/arc_cse.svg b/assets/icons/arc/arc_cse.svg
index d38120c06..f18a98ef0 100644
--- a/assets/icons/arc/arc_cse.svg
+++ b/assets/icons/arc/arc_cse.svg
@@ -1,9 +1,9 @@
diff --git a/assets/icons/arc/arc_csl.svg b/assets/icons/arc/arc_csl.svg
index 3d9005332..ea64f9f8f 100644
--- a/assets/icons/arc/arc_csl.svg
+++ b/assets/icons/arc/arc_csl.svg
@@ -1,9 +1,9 @@
diff --git a/assets/icons/arc/arc_sca.svg b/assets/icons/arc/arc_sca.svg
index bd1c5895b..a313a71ab 100644
--- a/assets/icons/arc/arc_sca.svg
+++ b/assets/icons/arc/arc_sca.svg
@@ -1,10 +1,10 @@
diff --git a/assets/icons/arc/arc_sce.svg b/assets/icons/arc/arc_sce.svg
index f9773ff7a..6972a321a 100644
--- a/assets/icons/arc/arc_sce.svg
+++ b/assets/icons/arc/arc_sce.svg
@@ -1,9 +1,9 @@
diff --git a/assets/icons/arc/arc_scl.svg b/assets/icons/arc/arc_scl.svg
index 99c7f213c..18d03dfd6 100644
--- a/assets/icons/arc/arc_scl.svg
+++ b/assets/icons/arc/arc_scl.svg
@@ -1,9 +1,9 @@
diff --git a/assets/icons/arc/arc_sea.svg b/assets/icons/arc/arc_sea.svg
index dc7b2b0e4..9bc3d9c69 100644
--- a/assets/icons/arc/arc_sea.svg
+++ b/assets/icons/arc/arc_sea.svg
@@ -4,7 +4,7 @@
-
-
-
+
+
+
diff --git a/assets/icons/arc/arc_sed.svg b/assets/icons/arc/arc_sed.svg
index d63a0dd71..62bcf4386 100644
--- a/assets/icons/arc/arc_sed.svg
+++ b/assets/icons/arc/arc_sed.svg
@@ -2,8 +2,8 @@
-
-
-
-
+
+
+
+
diff --git a/assets/icons/arc/arc_ser.svg b/assets/icons/arc/arc_ser.svg
index e4b14386e..06280eb70 100644
--- a/assets/icons/arc/arc_ser.svg
+++ b/assets/icons/arc/arc_ser.svg
@@ -2,10 +2,10 @@
-
+
-
-
-
-
+
+
+
+
diff --git a/assets/icons/array_path.svg b/assets/icons/array_path.svg
index 4bf13082a..a8289526e 100644
--- a/assets/icons/array_path.svg
+++ b/assets/icons/array_path.svg
@@ -16,7 +16,7 @@
-
+
diff --git a/assets/icons/base_point.svg b/assets/icons/base_point.svg
index bcf094c08..55b15b85d 100644
--- a/assets/icons/base_point.svg
+++ b/assets/icons/base_point.svg
@@ -1,5 +1,5 @@
diff --git a/assets/icons/blocks/preview_size.svg b/assets/icons/blocks/preview_size.svg
index 537a29d9c..76a81b474 100644
--- a/assets/icons/blocks/preview_size.svg
+++ b/assets/icons/blocks/preview_size.svg
@@ -1,5 +1,5 @@
diff --git a/assets/icons/bylayer.svg b/assets/icons/bylayer.svg
index 5b8acbbe0..ec352aea7 100644
--- a/assets/icons/bylayer.svg
+++ b/assets/icons/bylayer.svg
@@ -1,5 +1,5 @@
diff --git a/assets/icons/chamfer.svg b/assets/icons/chamfer.svg
index d8183370c..f2ce9d585 100644
--- a/assets/icons/chamfer.svg
+++ b/assets/icons/chamfer.svg
@@ -16,7 +16,7 @@
-
-
+
+
diff --git a/assets/icons/circle/circle_2p.svg b/assets/icons/circle/circle_2p.svg
index 2589c3232..309ed1f5f 100644
--- a/assets/icons/circle/circle_2p.svg
+++ b/assets/icons/circle/circle_2p.svg
@@ -1,6 +1,6 @@
diff --git a/assets/icons/circle/circle_3p.svg b/assets/icons/circle/circle_3p.svg
index 27fef5f24..980ba83c8 100644
--- a/assets/icons/circle/circle_3p.svg
+++ b/assets/icons/circle/circle_3p.svg
@@ -1,9 +1,9 @@
diff --git a/assets/icons/circle/circle_cd.svg b/assets/icons/circle/circle_cd.svg
index 8aae115a5..f90491b55 100644
--- a/assets/icons/circle/circle_cd.svg
+++ b/assets/icons/circle/circle_cd.svg
@@ -1,5 +1,5 @@
diff --git a/assets/icons/circle/circle_cr.svg b/assets/icons/circle/circle_cr.svg
index ee9fdfc31..fdc304364 100644
--- a/assets/icons/circle/circle_cr.svg
+++ b/assets/icons/circle/circle_cr.svg
@@ -1,5 +1,5 @@
diff --git a/assets/icons/circle/circle_ttr.svg b/assets/icons/circle/circle_ttr.svg
index 6cfa73005..a3a8579cd 100644
--- a/assets/icons/circle/circle_ttr.svg
+++ b/assets/icons/circle/circle_ttr.svg
@@ -2,6 +2,6 @@
-
-
+
+
diff --git a/assets/icons/circle/circle_ttt.svg b/assets/icons/circle/circle_ttt.svg
index 837ad538b..821dc0fea 100644
--- a/assets/icons/circle/circle_ttt.svg
+++ b/assets/icons/circle/circle_ttt.svg
@@ -1,4 +1,4 @@
diff --git a/assets/icons/clear_tool.svg b/assets/icons/clear_tool.svg
index af63ef8ab..03edeaaf5 100644
--- a/assets/icons/clear_tool.svg
+++ b/assets/icons/clear_tool.svg
@@ -2,5 +2,5 @@
-
+
diff --git a/assets/icons/dim_angular.svg b/assets/icons/dim_angular.svg
index 4929a339d..a81368280 100644
--- a/assets/icons/dim_angular.svg
+++ b/assets/icons/dim_angular.svg
@@ -3,9 +3,9 @@
-
+
-
+
- ∠
+ ∠
diff --git a/assets/icons/ellipse.svg b/assets/icons/ellipse.svg
index 9698d5901..535acb7a1 100644
--- a/assets/icons/ellipse.svg
+++ b/assets/icons/ellipse.svg
@@ -1,5 +1,5 @@
diff --git a/assets/icons/ellipse/ellipse_arc.svg b/assets/icons/ellipse/ellipse_arc.svg
index 83e09c766..07edf2f6d 100644
--- a/assets/icons/ellipse/ellipse_arc.svg
+++ b/assets/icons/ellipse/ellipse_arc.svg
@@ -16,7 +16,7 @@
diff --git a/assets/icons/ellipse/ellipse_axis.svg b/assets/icons/ellipse/ellipse_axis.svg
index 2a893ec86..86c31d3d4 100644
--- a/assets/icons/ellipse/ellipse_axis.svg
+++ b/assets/icons/ellipse/ellipse_axis.svg
@@ -2,13 +2,13 @@
-
+
-
+
-
+
-
+
-
+
diff --git a/assets/icons/ellipse/ellipse_ctr.svg b/assets/icons/ellipse/ellipse_ctr.svg
index 581d00cde..e6ce7a9ee 100644
--- a/assets/icons/ellipse/ellipse_ctr.svg
+++ b/assets/icons/ellipse/ellipse_ctr.svg
@@ -2,9 +2,9 @@
-
+
-
+
-
+
diff --git a/assets/icons/explode.svg b/assets/icons/explode.svg
index dc5e18e3d..eedbfa408 100644
--- a/assets/icons/explode.svg
+++ b/assets/icons/explode.svg
@@ -16,7 +16,7 @@
-
-
+
+
diff --git a/assets/icons/fillet.svg b/assets/icons/fillet.svg
index bf4392df6..ea9829cdc 100644
--- a/assets/icons/fillet.svg
+++ b/assets/icons/fillet.svg
@@ -16,7 +16,7 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/assets/icons/hatch/hatch_cross.svg b/assets/icons/hatch/hatch_cross.svg
index 9b67237c2..0461f16ef 100644
--- a/assets/icons/hatch/hatch_cross.svg
+++ b/assets/icons/hatch/hatch_cross.svg
@@ -7,15 +7,15 @@
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
diff --git a/assets/icons/hatch/hatch_gradient.svg b/assets/icons/hatch/hatch_gradient.svg
index e7d11c0db..199d8a941 100644
--- a/assets/icons/hatch/hatch_gradient.svg
+++ b/assets/icons/hatch/hatch_gradient.svg
@@ -1,8 +1,8 @@
diff --git a/assets/icons/import_obj.svg b/assets/icons/import_obj.svg
index 96f25eea5..4cdbd1b82 100644
--- a/assets/icons/import_obj.svg
+++ b/assets/icons/import_obj.svg
@@ -1,4 +1,4 @@
-
+
diff --git a/assets/icons/layers/layfrz.svg b/assets/icons/layers/layfrz.svg
index 0ac9f8ecb..4d165d848 100644
--- a/assets/icons/layers/layfrz.svg
+++ b/assets/icons/layers/layfrz.svg
@@ -16,7 +16,7 @@
@@ -66,7 +66,7 @@
y1="4.1414943"
x2="4.1414943"
y2="19.858505"
- stroke="#4cc9f0"
+ stroke="#6DB7ED"
stroke-width="2.20091"
stroke-linecap="round"
id="line4"
diff --git a/assets/icons/layers/layiso.svg b/assets/icons/layers/layiso.svg
index 9337d6912..cc5db5535 100644
--- a/assets/icons/layers/layiso.svg
+++ b/assets/icons/layers/layiso.svg
@@ -8,9 +8,9 @@
stroke-dasharray="3 3"/>
+ stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
+ fill="#6DB7ED" stroke="#B4B6B9" stroke-width="1.25"/>
diff --git a/assets/icons/layers/laymcur.svg b/assets/icons/layers/laymcur.svg
index 97da73db7..5e0365b0e 100644
--- a/assets/icons/layers/laymcur.svg
+++ b/assets/icons/layers/laymcur.svg
@@ -16,7 +16,7 @@
+ stroke="#B4B6B9" stroke-width="2.25" stroke-linecap="round"/>
+ stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/>
+ stroke="#B4B6B9" stroke-width="2.25" stroke-linecap="round"/>
-
-
+
+
diff --git a/assets/icons/loft.svg b/assets/icons/loft.svg
index 99e9cf624..11c04339b 100644
--- a/assets/icons/loft.svg
+++ b/assets/icons/loft.svg
@@ -1,4 +1,4 @@
-
-
+
+
diff --git a/assets/icons/match_prop.svg b/assets/icons/match_prop.svg
index 1ff276498..6353e5a95 100644
--- a/assets/icons/match_prop.svg
+++ b/assets/icons/match_prop.svg
@@ -1,39 +1,8 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/assets/icons/mirror.svg b/assets/icons/mirror.svg
index 64611385a..b76995d59 100644
--- a/assets/icons/mirror.svg
+++ b/assets/icons/mirror.svg
@@ -16,7 +16,7 @@
-
-
+
+
diff --git a/assets/icons/ortho.svg b/assets/icons/ortho.svg
index 8e807e43c..211991661 100644
--- a/assets/icons/ortho.svg
+++ b/assets/icons/ortho.svg
@@ -1,7 +1,7 @@
-
+
-
-
+
+
diff --git a/assets/icons/pagesetup.svg b/assets/icons/pagesetup.svg
index 28b79d33c..f707ff1b9 100644
--- a/assets/icons/pagesetup.svg
+++ b/assets/icons/pagesetup.svg
@@ -1,4 +1,4 @@
-
+
diff --git a/assets/icons/persp.svg b/assets/icons/persp.svg
index 746e74c64..3e90aa383 100644
--- a/assets/icons/persp.svg
+++ b/assets/icons/persp.svg
@@ -1,8 +1,8 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/assets/icons/plot.svg b/assets/icons/plot.svg
index 560575226..bcd78b90f 100644
--- a/assets/icons/plot.svg
+++ b/assets/icons/plot.svg
@@ -1,5 +1,5 @@
-
+
diff --git a/assets/icons/polyline.svg b/assets/icons/polyline.svg
index 738337b43..4a0c8619d 100644
--- a/assets/icons/polyline.svg
+++ b/assets/icons/polyline.svg
@@ -1,7 +1,7 @@
-
-
-
-
+
+
+
+
diff --git a/assets/icons/polysolid.svg b/assets/icons/polysolid.svg
index 8532c376b..aa85c4e69 100644
--- a/assets/icons/polysolid.svg
+++ b/assets/icons/polysolid.svg
@@ -1,4 +1,4 @@
-
-
+
+
diff --git a/assets/icons/presspull.svg b/assets/icons/presspull.svg
index 189e6909f..ce5bcb45e 100644
--- a/assets/icons/presspull.svg
+++ b/assets/icons/presspull.svg
@@ -1,4 +1,4 @@
-
-
+
+
diff --git a/assets/icons/purge.svg b/assets/icons/purge.svg
index 2e0131c8c..185aa1cc2 100644
--- a/assets/icons/purge.svg
+++ b/assets/icons/purge.svg
@@ -2,6 +2,6 @@
-
-
+
+
diff --git a/assets/icons/pyramid3d.svg b/assets/icons/pyramid3d.svg
index a7d26f91f..e83db6e7c 100644
--- a/assets/icons/pyramid3d.svg
+++ b/assets/icons/pyramid3d.svg
@@ -1,4 +1,4 @@
-
-
+
+
diff --git a/assets/icons/recap.svg b/assets/icons/recap.svg
index b02d83d73..e466f086f 100644
--- a/assets/icons/recap.svg
+++ b/assets/icons/recap.svg
@@ -1,6 +1,6 @@
-
+
diff --git a/assets/icons/revolve.svg b/assets/icons/revolve.svg
index 48073ba99..c621cb8c5 100644
--- a/assets/icons/revolve.svg
+++ b/assets/icons/revolve.svg
@@ -1,4 +1,4 @@
-
-
+
+
diff --git a/assets/icons/rotate.svg b/assets/icons/rotate.svg
index 230814737..c63200912 100644
--- a/assets/icons/rotate.svg
+++ b/assets/icons/rotate.svg
@@ -16,7 +16,7 @@
-
+
-
-
-
+
+
+
diff --git a/assets/icons/shapes/poly_e.svg b/assets/icons/shapes/poly_e.svg
index ac0de9313..c2ad056e4 100644
--- a/assets/icons/shapes/poly_e.svg
+++ b/assets/icons/shapes/poly_e.svg
@@ -1,6 +1,6 @@
-
-
-
+
+
+
diff --git a/assets/icons/shapes/poly_i.svg b/assets/icons/shapes/poly_i.svg
index aaad3a4e1..82724a925 100644
--- a/assets/icons/shapes/poly_i.svg
+++ b/assets/icons/shapes/poly_i.svg
@@ -1,7 +1,7 @@
-
+
-
-
-
+
+
+
diff --git a/assets/icons/shapes/rect.svg b/assets/icons/shapes/rect.svg
index 480ec1549..836755232 100644
--- a/assets/icons/shapes/rect.svg
+++ b/assets/icons/shapes/rect.svg
@@ -16,7 +16,7 @@
diff --git a/assets/icons/shapes/rect_cen.svg b/assets/icons/shapes/rect_cen.svg
index 1a44c9ea7..2afff5fe6 100644
--- a/assets/icons/shapes/rect_cen.svg
+++ b/assets/icons/shapes/rect_cen.svg
@@ -1,6 +1,6 @@
-
-
-
+
+
+
diff --git a/assets/icons/shapes/rect_rot.svg b/assets/icons/shapes/rect_rot.svg
index 79e48390b..c4a68ca4c 100644
--- a/assets/icons/shapes/rect_rot.svg
+++ b/assets/icons/shapes/rect_rot.svg
@@ -1,6 +1,6 @@
-
-
-
+
+
+
diff --git a/assets/icons/solid.svg b/assets/icons/solid.svg
index c1dff7f59..adbf7c046 100644
--- a/assets/icons/solid.svg
+++ b/assets/icons/solid.svg
@@ -1,5 +1,5 @@
-
-
-
+
+
+
diff --git a/assets/icons/stretch.svg b/assets/icons/stretch.svg
index 53d6598de..b8bb66a58 100644
--- a/assets/icons/stretch.svg
+++ b/assets/icons/stretch.svg
@@ -16,7 +16,7 @@
diff --git a/assets/icons/subtract.svg b/assets/icons/subtract.svg
index 30bd26b4d..5339687c8 100644
--- a/assets/icons/subtract.svg
+++ b/assets/icons/subtract.svg
@@ -1,4 +1,4 @@
-
+
diff --git a/assets/icons/sweep.svg b/assets/icons/sweep.svg
index 4db7d3736..736a8368a 100644
--- a/assets/icons/sweep.svg
+++ b/assets/icons/sweep.svg
@@ -1,4 +1,4 @@
-
-
+
+
diff --git a/assets/icons/sync.svg b/assets/icons/sync.svg
index 0a1cb6c53..031275ec5 100644
--- a/assets/icons/sync.svg
+++ b/assets/icons/sync.svg
@@ -1,4 +1,4 @@
-
+
diff --git a/assets/icons/trim.svg b/assets/icons/trim.svg
index 4d2fad8c4..abf2fb07f 100644
--- a/assets/icons/trim.svg
+++ b/assets/icons/trim.svg
@@ -16,7 +16,7 @@
diff --git a/assets/icons/ui/redo.svg b/assets/icons/ui/redo.svg
index 79d952145..d2054eb7e 100644
--- a/assets/icons/ui/redo.svg
+++ b/assets/icons/ui/redo.svg
@@ -19,7 +19,7 @@
-
+
diff --git a/assets/icons/wedge3d.svg b/assets/icons/wedge3d.svg
index 793b57468..9adfadf33 100644
--- a/assets/icons/wedge3d.svg
+++ b/assets/icons/wedge3d.svg
@@ -2,5 +2,5 @@
-
+
diff --git a/assets/icons/wipeout.svg b/assets/icons/wipeout.svg
index 5665b83c5..01d32e41e 100644
--- a/assets/icons/wipeout.svg
+++ b/assets/icons/wipeout.svg
@@ -5,7 +5,7 @@
-
+
-
+
diff --git a/assets/icons/xadjust.svg b/assets/icons/xadjust.svg
index 1aa63898d..4804c00d6 100644
--- a/assets/icons/xadjust.svg
+++ b/assets/icons/xadjust.svg
@@ -1,8 +1,8 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/assets/icons/xclip.svg b/assets/icons/xclip.svg
index ddf1df758..d8c0df758 100644
--- a/assets/icons/xclip.svg
+++ b/assets/icons/xclip.svg
@@ -1,6 +1,6 @@
-
-
-
+
+
+
diff --git a/assets/icons/xray.svg b/assets/icons/xray.svg
index 6aa7d9c5f..7fa661779 100644
--- a/assets/icons/xray.svg
+++ b/assets/icons/xray.svg
@@ -2,7 +2,7 @@
-
-
-
+
+
+
diff --git a/assets/icons/zoom_in.svg b/assets/icons/zoom_in.svg
index 78821bf99..958ba0d29 100644
--- a/assets/icons/zoom_in.svg
+++ b/assets/icons/zoom_in.svg
@@ -1,6 +1,6 @@
-
-
+
+
diff --git a/assets/icons/zoom_out.svg b/assets/icons/zoom_out.svg
index 2ea3fe424..352fa0ae3 100644
--- a/assets/icons/zoom_out.svg
+++ b/assets/icons/zoom_out.svg
@@ -1,5 +1,5 @@
-
+
diff --git a/assets/icons/zoom_window.svg b/assets/icons/zoom_window.svg
index bc4e46712..a04b2678c 100644
--- a/assets/icons/zoom_window.svg
+++ b/assets/icons/zoom_window.svg
@@ -1,5 +1,5 @@
-
+
diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md
index 20e77acc8..08761e7db 100644
--- a/crates/ocs_plugin_api/ARCHITECTURE.md
+++ b/crates/ocs_plugin_api/ARCHITECTURE.md
@@ -44,16 +44,18 @@ The host and each plugin run in separate OS processes. The host re-executes itse
## Versioning and ABI stability
-- `API_VERSION` (currently `4`) is the host's advertised major.
+- `API_VERSION` (currently `5`) is the host's advertised major.
- `API_VERSION_MIN_SUPPORTED` (currently `2`) is the oldest plugin major the host loads.
-- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `3` to disable V4).
+- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `4` to disable V5).
- A plugin built against major `N` runs on a host whose major is `>= N` because new vtable entries and enum variants are appended at the end.
- V4 introduces the **acadrust gate**: plugins targeting API v4 or later must resolve the same `acadrust` source as the host (see [`src/version_info.rs`](src/version_info.rs)).
+- V5 introduces `BuiltinPlugin::on_load`, invoked once after the runner connects.
-The runtime enforces two gates:
+The runtime enforces three gates:
1. `ocs_plugin_api_version()` exported by the cdylib must be within `[API_VERSION_MIN_SUPPORTED, effective_max_api_version()]`.
2. For v4+, `acadrust_sources_compatible(host_acadrust_source(), plugin_acadrust_source)` must be true.
+3. For v4+, the plugin must declare `rustc_version`, and `rustc_versions_compatible(host_rustc_version(), plugin_rustc_version)` must be true.
---
@@ -71,18 +73,21 @@ sequenceDiagram
PP->>R: spawn --ocs-plugin-runner
R->>L: unsafe { load(cdylib_path) }
L-->>R: Box
- alt API v4
+ alt API v4+
R->>PP: connect + RunnerHandshake::TokenV4
PP->>PP: verify token + V4 protocol/API gate
else API v2/v3
R->>PP: connect + RunnerHandshake::Token
PP->>PP: verify token
end
+ opt API v5+
+ R->>L: on_load(HostApi)
+ end
PP->>R: HostToPlugin::Request(GetManifest)
R->>L: manifest()
L-->>R: PluginManifest { api_version }
R-->>PP: HostResponse::Manifest
- PP->>PP: verify api_version + acadrust gate
+ PP->>PP: verify api_version + ABI metadata gates
alt accepted
PP-->>PM: success, keep alive
else rejected
@@ -215,7 +220,7 @@ Broadcasting is V4-gated inside `PluginManager::broadcast_notification`: V2/V3 p
The crate embeds two JSON blobs at build time:
- **Type registry** (`OUT_DIR/type_registry.json`) — a language-binding-friendly schema for a curated allow-list of `acadrust` types, generated by `serde-reflection` in `build.rs`. See [`src/type_registry.rs`](src/type_registry.rs).
-- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs).
+- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, `rustc` version, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs).
Both are accessible without enabling the `host` feature.
@@ -242,6 +247,7 @@ This is defense-in-depth, not a sandbox: plugins execute native code.
| Spawn timeout or crash | Spawn fails; plugin not loaded; error surfaced in UI/logs. |
| Version mismatch | Host refuses plugin before running plugin code. |
| Acadrust source mismatch (v4+) | Host refuses plugin to avoid binary incompatibilities. |
+| Rustc mismatch (v4+) | Host refuses plugin to avoid cross-compiler ABI crashes. |
| Unknown notification discriminant | Deserializes to `HostNotification::Unknown`; plugin can ignore. |
---
diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md
index 25021c8ec..1e01c6438 100644
--- a/crates/ocs_plugin_api/README.md
+++ b/crates/ocs_plugin_api/README.md
@@ -9,7 +9,7 @@ cheaply. The runtime host surface is enabled by the `host` feature.
## Plugin Architecture
OCS is scriptable and extensible through a versioned plugin API. The API
-supports three major protocol generations: **V2**, **V3**, and **V4**. Each
+supports four major generations: **V2**, **V3**, **V4**, and **V5**. Each
generation keeps the previous ABI stable by appending new vtable entries and new
enum variants at the end, so older plugins continue to load on newer hosts.
@@ -18,12 +18,17 @@ enum variants at the end, so older plugins continue to load on newer hosts.
| **V2** | Out-of-process runner | Synchronous commands + interactive point collection (`HostApi::start_interactive`) | Baseline |
| **V3** | Out-of-process runner | Local cached `document()`/`document_mut()` + shared-memory `DocumentReader`/`document_view` for large reads | New vtable entries appended; new `PluginRequest`/`PluginResponse` variants appended |
| **V4** | Out-of-process runner over a multiplexed local socket | Full-duplex notifications + asynchronous REPL `ExecuteCode` | New `HostToPluginV4`/`PluginToHostV4` frame layer; `BuiltinPlugin::on_notification` and `start_execute_code` appended to trait |
+| **V5** | V4 multiplexed runner | One-shot `BuiltinPlugin::on_load` initialization | Lifecycle callback appended to `BuiltinPlugin` |
The host controls which generations are accepted at runtime through
-`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `3` to disable V4). The
-current host advertises [`API_VERSION = 4`](src/manifest.rs) and supports plugins
+`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `4` to disable V5). The
+current host advertises [`API_VERSION = 5`](src/manifest.rs) and supports plugins
back to `API_VERSION_MIN_SUPPORTED = 2`.
+V5 calls `BuiltinPlugin::on_load` once after the runner connects and before the
+first user command. Plugins can cache `HostApi::plugin_request_sender()` there
+for worker-thread access. A panic in the callback fails plugin loading.
+
### High-level layout
```mermaid
@@ -263,7 +268,7 @@ static MANIFEST: PluginManifest = PluginManifest {
name: "Hello Plugin",
version: "0.1.0",
description: "A minimal example plugin.",
- api_version: ApiVersion { major: 4 },
+ api_version: ApiVersion::CURRENT,
ribbon_order: 100,
xdata_apps: &[],
command_prefixes: &["HELLO"],
diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs
index 480f2435f..867318fd0 100644
--- a/crates/ocs_plugin_api/build.rs
+++ b/crates/ocs_plugin_api/build.rs
@@ -2,6 +2,7 @@ use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
+use std::process::Command;
use std::time::SystemTime;
use cargo_lock::Lockfile;
@@ -383,12 +384,27 @@ fn generate_version_info(out_dir: &Path) {
.find(|p| p.name.as_str() == "acadrust")
.expect("acadrust package in Cargo.lock");
+ let rustc = env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
+ let rustc_version = Command::new(rustc)
+ .arg("--version")
+ .output()
+ .ok()
+ .and_then(|o| {
+ if o.status.success() {
+ Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
+ } else {
+ None
+ }
+ })
+ .unwrap_or_default();
+
let info = serde_json::json!({
"ocs_version": ocs.version.to_string(),
"ocs_plugin_api_version": env!("CARGO_PKG_VERSION"),
"acadrust_version": acadrust.version.to_string(),
"acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()),
- "api_version": 4,
+ "rustc_version": rustc_version,
+ "api_version": 5,
"api_version_min_supported": 2,
"build_timestamp": build_timestamp,
});
diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs
index 8a78042a0..13bde0353 100644
--- a/crates/ocs_plugin_api/src/host.rs
+++ b/crates/ocs_plugin_api/src/host.rs
@@ -299,6 +299,14 @@ pub trait BuiltinPlugin: Send + Sync {
/// the V4 runner guarantees it is only called for plugins that report
/// API major 4 or newer.
fn on_notification(&mut self, _command_id: Option, _notification: HostNotification) {}
+
+ /// Lifecycle callback invoked once after the runner connects. The plugin
+ /// receives the active document tab's `HostApi` so it can set up worker
+ /// threads or cache `plugin_request_sender()` before any user command runs.
+ ///
+ /// Added in API v5. The runner only calls this for plugins that report API
+ /// major 5 or newer.
+ fn on_load(&mut self, _host: &mut dyn HostApi) {}
}
/// A point-driven interactive command a plugin starts via
diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs
index 39352e8ae..c0113e126 100644
--- a/crates/ocs_plugin_api/src/manifest.rs
+++ b/crates/ocs_plugin_api/src/manifest.rs
@@ -17,8 +17,9 @@
/// `document()` / `document_mut()` to local cached copies for out-of-process
/// plugins and appends `document_reader` / `document_view` at the end of the
/// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on
-/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched.
-pub const API_VERSION: u32 = 4;
+/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5
+/// adds the `BuiltinPlugin::on_load` lifecycle callback.
+pub const API_VERSION: u32 = 5;
/// Oldest plugin API major the current host still loads. This keeps previously
/// compiled cdylibs usable as long as their vtable layout is a prefix of the
@@ -26,9 +27,8 @@ pub const API_VERSION: u32 = 4;
pub const API_VERSION_MIN_SUPPORTED: u32 = 2;
/// Environment variable that caps the API major accepted by the host at
-/// runtime. Set to `3` to disable V4 plugins while keeping V2/V3 plugins
-/// working; set to `2` for V2-only mode. The same mechanism will apply to
-/// future API majors.
+/// runtime. Set to `4` to disable V5 plugins, `3` to disable V4, or `2` for
+/// V2-only mode.
pub const MAX_API_VERSION_ENV: &str = "OCS_PLUGIN_MAX_API_VERSION";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs
index 03f0097fb..b1d89ab0c 100644
--- a/crates/ocs_plugin_api/src/runner.rs
+++ b/crates/ocs_plugin_api/src/runner.rs
@@ -56,15 +56,33 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box= 4 {
let client = V4Client::connect_handshake(socket_name, &token)?;
+ if version >= 5 {
+ invoke_on_load(&mut *plugin, &client, &interactive)?;
+ }
run_v4(&mut *plugin, &interactive, client)
} else {
let client = IpcClient::connect(socket_name)?;
eprintln!("[runner] connected to host");
client.send_handshake(&token)?;
+ // V2/V3 plugins receive HostApi only during dispatch, so on_load is not
+ // meaningful there; skip it to keep the old protocol unchanged.
run_v3(&*plugin, &interactive, client)
}
}
+fn invoke_on_load(
+ plugin: &mut dyn BuiltinPlugin,
+ client: &V4Client,
+ interactive: &InteractiveRegistry,
+) -> Result<(), Box> {
+ let mut proxy = client.plugin_host_api(0, interactive.clone());
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ plugin.on_load(&mut proxy)
+ }))
+ .map_err(|_| std::io::Error::other("plugin on_load() panicked"))?;
+ Ok(())
+}
+
fn run_v3(
plugin: &dyn BuiltinPlugin,
interactive: &InteractiveRegistry,
diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs
index 35c870cde..aa74a81ab 100644
--- a/crates/ocs_plugin_api/src/version_info.rs
+++ b/crates/ocs_plugin_api/src/version_info.rs
@@ -11,6 +11,12 @@
//! `acadrust` source with its own via [`acadrust_sources_compatible`]. Two
//! sources are considered compatible only when they resolve to the same 40
//! character git commit hash.
+//!
+//! The rustc version string is also recorded because Rust has no stable ABI:
+//! a plugin built with a different compiler than the host can pass simple calls
+//! and then crash the runner on compound types. For API v4 and later the host
+//! can compare the plugin's declared rustc with its own via
+//! [`rustc_versions_compatible`].
use std::sync::OnceLock;
@@ -26,6 +32,7 @@ pub fn get_embedded_version_info_json() -> &'static str {
#[derive(Debug, Clone)]
struct VersionInfo {
acadrust_source: String,
+ rustc_version: String,
}
pub const ACADRUST_GATE_API_VERSION: u32 = 4;
@@ -48,6 +55,8 @@ fn parsed_version_info() -> &'static VersionInfo {
INFO.get_or_init(|| VersionInfo {
acadrust_source: json_string_value(EMBEDDED_VERSION_INFO_JSON, "acadrust_source")
.unwrap_or_default(),
+ rustc_version: json_string_value(EMBEDDED_VERSION_INFO_JSON, "rustc_version")
+ .unwrap_or_default(),
})
}
@@ -56,6 +65,30 @@ pub fn host_acadrust_source() -> &'static str {
&parsed_version_info().acadrust_source
}
+/// Returns the host's `rustc --version` output.
+pub fn host_rustc_version() -> &'static str {
+ &parsed_version_info().rustc_version
+}
+
+/// Returns whether two `rustc --version` outputs are considered compatible.
+///
+/// The full version line (e.g. "rustc 1.98.0 (hash date)") is compared
+/// token-by-token after normalising whitespace. This is intentionally strict:
+/// Rust has no stable ABI, so even the same nominal compiler built in different
+/// environments can produce incompatible layouts.
+pub fn rustc_versions_compatible(a: &str, b: &str) -> bool {
+ if a.is_empty() || b.is_empty() {
+ return false;
+ }
+ let a_tokens: Vec<&str> = a.split_whitespace().collect();
+ let b_tokens: Vec<&str> = b.split_whitespace().collect();
+ a_tokens.len() == b_tokens.len()
+ && a_tokens
+ .iter()
+ .zip(b_tokens)
+ .all(|(a, b)| a.eq_ignore_ascii_case(b))
+}
+
/// Extracts the full git commit hash from a Cargo source.
pub fn acadrust_source_hash(source: &str) -> Option<&str> {
let hash = source.rsplit('#').next()?;
@@ -85,6 +118,7 @@ mod tests {
let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
assert!(value.get("ocs_version").is_some());
assert!(value.get("acadrust_source").is_some());
+ assert!(value.get("rustc_version").is_some());
}
#[test]
@@ -96,6 +130,7 @@ mod tests {
"ocs_plugin_api_version",
"acadrust_version",
"acadrust_source",
+ "rustc_version",
"api_version",
"api_version_min_supported",
"build_timestamp",
@@ -112,7 +147,7 @@ mod tests {
value["ocs_plugin_api_version"].as_str(),
Some(env!("CARGO_PKG_VERSION"))
);
- assert_eq!(value["api_version"].as_i64(), Some(4));
+ assert_eq!(value["api_version"].as_i64(), Some(5));
assert_eq!(value["api_version_min_supported"].as_i64(), Some(2));
// acadrust_version should have a patch component (e.g. "0.4.0").
@@ -125,6 +160,45 @@ mod tests {
assert!(!host_acadrust_source().is_empty());
}
+ #[test]
+ fn host_rustc_version_is_non_empty() {
+ assert!(!host_rustc_version().is_empty());
+ }
+
+ #[test]
+ fn rustc_version_comparison_is_strict() {
+ assert!(rustc_versions_compatible(
+ "rustc 1.98.0 (abc123 2026-01-01)",
+ "rustc 1.98.0 (abc123 2026-01-01)"
+ ));
+ assert!(!rustc_versions_compatible(
+ "rustc 1.96.0 (def456 2025-11-01)",
+ "rustc 1.98.0 (abc123 2026-01-01)"
+ ));
+ }
+
+ #[test]
+ fn rustc_version_comparison_normalises_whitespace() {
+ assert!(rustc_versions_compatible(
+ "rustc 1.98.0 (abc123 2026-01-01)",
+ "rustc 1.98.0 (abc123 2026-01-01)"
+ ));
+ }
+
+ #[test]
+ fn rustc_version_comparison_rejects_empty() {
+ assert!(!rustc_versions_compatible("", "rustc 1.98.0"));
+ assert!(!rustc_versions_compatible("rustc 1.98.0", ""));
+ assert!(!rustc_versions_compatible("", ""));
+ }
+
+ #[test]
+ fn rustc_version_comparison_rejects_collapsed_tokens() {
+ // A concatenated version must not accidentally match a spaced one.
+ assert!(!rustc_versions_compatible("rustc 1.98.0", "rustc1.98.0"));
+ assert!(!rustc_versions_compatible("rustc1.98.0", "rustc 1.98.0"));
+ }
+
#[test]
fn dependency_gate_starts_at_api_v4() {
assert!(!uses_acadrust_gate(3));
diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md
index dc1a8d0aa..d27612f24 100644
--- a/docs/plugin-architecture.md
+++ b/docs/plugin-architecture.md
@@ -89,6 +89,25 @@ plugin compiles against. Two tiers:
A plugin enables the `host` feature.
+### Build-time ABI metadata
+
+For API v4 and later the host records two pieces of build-time metadata that
+affect whether a prebuilt plugin can safely be loaded:
+
+- `acadrust_source` — the exact git source of the `acadrust` crate the host was
+ built against.
+- `rustc_version` — the output of `rustc --version` for the compiler that built
+ the host.
+
+A plugin's `plugin.toml` declares the same values under `[opencad]`. For API v4
+and later, `rustc_version` is required and the host refuses to load the plugin
+unless it matches exactly. This turns the current silent runner crash into a
+one-line diagnosis such as:
+
+```
+Plugin built with rustc 1.96.0, host requires 1.98.0 - rebuild required
+```
+
### `PluginManifest`
```rust
@@ -161,6 +180,10 @@ ocs_plugin_api = { git = "https://github.com/HakanSeven12/OpenCADStudio", featur
acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" }
```
+Your release build must also use the same `rustc` as the host. Record it in
+`plugin.toml` (see below) and pin the toolchain in your CI matrix so every
+published asset matches the host's compiler.
+
```rust
// src/lib.rs
use ocs_plugin_api::host::{BuiltinPlugin, HostApi};
@@ -206,10 +229,12 @@ version = "0.1.0"
description = "…"
[opencad]
-api_version = 3
+api_version = 5
ribbon_order = 50
command_prefixes = ["EX_"]
xdata_apps = []
+# Filled with the exact compiler output while staging the release asset.
+rustc_version = "rustc 1.98.0 (hash date)"
```
The full, buildable scaffold is in [`docs/plugin-template/`](plugin-template);
diff --git a/docs/plugin-template/.github/workflows/release.yml b/docs/plugin-template/.github/workflows/release.yml
index 2ff8de5f3..76588a1eb 100644
--- a/docs/plugin-template/.github/workflows/release.yml
+++ b/docs/plugin-template/.github/workflows/release.yml
@@ -36,7 +36,7 @@ jobs:
run: |
mkdir -p dist
cp target/release/*.${{ matrix.ext }} "dist/${{ matrix.asset }}"
- cp plugin.toml dist/plugin.toml
+ sed "s|__RUSTC_VERSION__|$(rustc --version)|" plugin.toml > dist/plugin.toml
- uses: softprops/action-gh-release@v2
with:
files: |
diff --git a/docs/plugin-template/README.md b/docs/plugin-template/README.md
index f6113df1a..52583adaf 100644
--- a/docs/plugin-template/README.md
+++ b/docs/plugin-template/README.md
@@ -28,7 +28,8 @@ folder into a new repo and rename the placeholders.
cargo build --release
mkdir -p "/OpenCADStudio/plugins/opencad.my_plugin"
cp target/release/*my_plugin*.so "/OpenCADStudio/plugins/opencad.my_plugin/"
-cp plugin.toml "/OpenCADStudio/plugins/opencad.my_plugin/"
+sed "s|__RUSTC_VERSION__|$(rustc --version)|" plugin.toml > \
+ "/OpenCADStudio/plugins/opencad.my_plugin/plugin.toml"
```
Restart Open CAD Studio: the ribbon tab appears and `MP_` commands route to your
diff --git a/docs/plugin-template/plugin.toml b/docs/plugin-template/plugin.toml
index f2718db54..d07f0b702 100644
--- a/docs/plugin-template/plugin.toml
+++ b/docs/plugin-template/plugin.toml
@@ -8,7 +8,8 @@ license = "GPL-3.0-only"
repository = "your-account/your-plugin-repo"
[opencad]
-api_version = 3
+api_version = 5
+rustc_version = "__RUSTC_VERSION__"
ribbon_order = 60
command_prefixes = ["MP_"]
xdata_apps = ["MYPLUGIN_RECORD"]
diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs
index 7a073e03d..f59c33cec 100644
--- a/src/app/view/mod.rs
+++ b/src/app/view/mod.rs
@@ -1435,13 +1435,23 @@ impl OpenCADStudio {
// Selection-cycling list box: pick among overlapping objects.
if let Some((pt, cands)) = &self.cycle_candidates {
if !tab.is_start {
- let items: Vec<(acadrust::Handle, String)> = cands
+ let items: Vec = cands
.iter()
.filter_map(|&h| {
- tab.scene
- .document
- .get_entity(h)
- .map(|e| (h, crate::entities::traits::entity_type_name(e).to_string()))
+ tab.scene.document.get_entity(h).map(|e| {
+ let style = crate::scene::view::render::render_style_for_viewport(
+ &tab.scene.document,
+ e,
+ None,
+ );
+ crate::ui::popup::cycle_popup::CycleCandidate {
+ handle: h,
+ type_name: crate::entities::traits::entity_type_name(e)
+ .to_string(),
+ layer: e.common().layer.clone(),
+ color: style.0,
+ }
+ })
})
.collect();
if !items.is_empty() {
diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs
index 7e3128101..6f4d3a680 100644
--- a/src/io/print_to_printer.rs
+++ b/src/io/print_to_printer.rs
@@ -45,7 +45,10 @@ pub struct PrintOptions {
pub printer: Option,
/// Number of copies (treated as at least 1).
pub copies: u32,
- /// Print quality label selected in the plot dialog.
+ /// Print quality label selected in the plot dialog. Read only on the CUPS
+ /// path (`lp -o print-quality=…`); on Windows the driver's own quality
+ /// setting wins, so the field is legitimately unread there.
+ #[cfg_attr(target_os = "windows", allow(dead_code))]
pub quality: Option,
/// Controls applied while building the intermediate PDF.
pub render: crate::io::pdf_export::PdfPlotOptions,
diff --git a/src/plugin/external.rs b/src/plugin/external.rs
index c395b1e59..493696def 100644
--- a/src/plugin/external.rs
+++ b/src/plugin/external.rs
@@ -36,6 +36,12 @@ pub struct ReleaseInfo {
pub acadrust_declared: bool,
/// Whether the release matches this host.
pub acadrust_compatible: bool,
+ /// Full `rustc --version` output used by the release.
+ pub rustc_version: Option,
+ /// Whether `[opencad]` declares `rustc_version`.
+ pub rustc_declared: bool,
+ /// Whether the release's rustc matches this host.
+ pub rustc_compatible: bool,
}
/// An add-on package found on disk (not necessarily loaded or compatible).
@@ -54,6 +60,10 @@ pub struct ExternalPlugin {
pub acadrust_source: Option,
/// Whether `[opencad]` declares `acadrust_source`.
pub acadrust_declared: bool,
+ /// Full `rustc --version` output used by the plugin.
+ pub rustc_version: Option,
+ /// Whether `[opencad]` declares `rustc_version`.
+ pub rustc_declared: bool,
pub ribbon_order: i32,
pub command_prefixes: Vec,
/// The package directory under the plugins folder.
@@ -85,10 +95,27 @@ impl ExternalPlugin {
}
}
+ /// Returns whether the package's rustc matches the host.
+ pub fn rustc_compatible(&self) -> bool {
+ if !ocs_plugin_api::version_info::uses_acadrust_gate(self.api_version) {
+ return true;
+ }
+ match self.rustc_version.as_deref() {
+ None | Some("") => false,
+ Some(version) => ocs_plugin_api::version_info::rustc_versions_compatible(
+ version,
+ ocs_plugin_api::version_info::host_rustc_version(),
+ ),
+ }
+ }
+
/// Returns whether the package can be loaded.
#[allow(dead_code)] // plugin-host surface (issue #100); not yet wired
pub fn loadable(&self) -> bool {
- self.api_compatible() && self.acadrust_compatible() && self.lib_present
+ self.api_compatible()
+ && self.acadrust_compatible()
+ && self.rustc_compatible()
+ && self.lib_present
}
}
@@ -204,6 +231,8 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option {
let mut api_version: u32 = 0;
let mut acadrust_source: Option = None;
let mut acadrust_declared = false;
+ let mut rustc_version: Option = None;
+ let mut rustc_declared = false;
let mut ribbon_order: i32 = 0;
let mut command_prefixes: Vec = Vec::new();
let mut section = None;
@@ -236,6 +265,11 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option {
let v = unquote(value);
acadrust_source = if v.is_empty() { None } else { Some(v) };
}
+ "rustc_version" if section == Some("opencad") => {
+ rustc_declared = true;
+ let v = unquote(value);
+ rustc_version = if v.is_empty() { None } else { Some(v) };
+ }
"ribbon_order" => ribbon_order = value.parse().unwrap_or(0),
"command_prefixes" => command_prefixes = parse_string_array(value),
_ => {}
@@ -251,6 +285,8 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option {
api_version,
acadrust_source,
acadrust_declared,
+ rustc_version,
+ rustc_declared,
ribbon_order,
command_prefixes,
dir: PathBuf::new(),
@@ -372,6 +408,17 @@ mod loader {
));
continue;
}
+ if !d.rustc_compatible() {
+ let host_rustc = ocs_plugin_api::version_info::host_rustc_version();
+ let plugin_rustc = d.rustc_version.as_deref().unwrap_or("unknown");
+ out.push((
+ d.id.clone(),
+ Err(format!(
+ "Plugin built with {plugin_rustc}, host requires {host_rustc} - rebuild required"
+ )),
+ ));
+ continue;
+ }
let Some(path) = lib_file(&d.dir) else {
out.push((
d.id.clone(),
@@ -483,6 +530,118 @@ xdata_apps = ["MYPLUGIN_RECORD"]
assert!(!p.loadable());
}
+ #[test]
+ fn undeclared_rustc_is_incompatible() {
+ let toml = r#"
+[plugin]
+id = "opencad.test"
+name = "Test"
+version = "0.1.0"
+api_version = 4
+"#;
+ let p = parse_plugin_toml(toml).expect("parsed");
+ assert!(!p.rustc_declared);
+ assert!(p.rustc_version.is_none());
+ assert!(!p.rustc_compatible());
+ assert!(!p.loadable());
+ }
+
+ #[test]
+ fn declared_empty_rustc_version_is_incompatible() {
+ let toml = r#"
+[plugin]
+id = "opencad.test"
+name = "Test"
+version = "0.1.0"
+api_version = 4
+
+[opencad]
+rustc_version = ""
+"#;
+ let p = parse_plugin_toml(toml).expect("parsed");
+ assert!(p.rustc_declared);
+ assert!(p.rustc_version.is_none());
+ assert!(!p.rustc_compatible(), "declared but empty rustc is incompatible");
+ assert!(!p.loadable());
+ }
+
+ #[test]
+ fn rustc_version_outside_opencad_is_ignored() {
+ let toml = r#"
+[plugin]
+id = "opencad.test"
+api_version = 4
+rustc_version = "rustc 1.98.0"
+"#;
+ let p = parse_plugin_toml(toml).expect("parsed");
+ assert!(!p.rustc_declared);
+ assert!(p.rustc_version.is_none());
+ }
+
+ #[test]
+ fn rustc_mismatch_detected() {
+ let _host = ocs_plugin_api::version_info::host_rustc_version();
+ let other = "rustc 0.0.0-fake (not matching anything)";
+ let toml = format!(
+ r#"
+[plugin]
+id = "opencad.test"
+name = "Test"
+version = "0.1.0"
+api_version = 4
+
+[opencad]
+rustc_version = "{other}"
+"#
+ );
+ let p = parse_plugin_toml(&toml).expect("parsed");
+ assert!(p.rustc_declared);
+ assert!(!p.rustc_compatible(), "mismatched rustc should be incompatible");
+ assert!(!p.loadable());
+ }
+
+ #[test]
+ fn rustc_match_detected() {
+ let host = ocs_plugin_api::version_info::host_rustc_version();
+ let toml = format!(
+ r#"
+[plugin]
+id = "opencad.test"
+name = "Test"
+version = "0.1.0"
+api_version = 4
+
+[opencad]
+rustc_version = "{host}"
+"#
+ );
+ let p = parse_plugin_toml(&toml).expect("parsed");
+ assert!(p.rustc_declared);
+ assert!(p.rustc_compatible(), "matching rustc should be compatible");
+ }
+
+ #[test]
+ fn rustc_gate_only_applies_to_api_v4_and_newer() {
+ let toml = r#"
+[plugin]
+id = "opencad.test"
+name = "Test"
+version = "0.1.0"
+api_version = 2
+
+[opencad]
+rustc_version = "rustc 0.0.0-fake"
+"#;
+ let mut p = parse_plugin_toml(toml).expect("parsed");
+ p.lib_present = true;
+ assert!(p.rustc_declared);
+ assert!(
+ p.rustc_compatible(),
+ "API v2 plugin should bypass rustc gate"
+ );
+ assert!(p.loadable());
+ }
+
#[test]
fn undeclared_acadrust_falls_back_to_api_gate() {
let toml = r#"
diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs
index f3f6c5f3d..1cd316e8e 100644
--- a/src/plugin/marketplace.rs
+++ b/src/plugin/marketplace.rs
@@ -236,12 +236,30 @@ pub fn fetch_release_info(repo: &str) -> Result, String> {
),
}
};
+ let rustc_version = manifest.rustc_version.clone();
+ let rustc_declared = manifest.rustc_declared;
+ let rustc_compatible = if !ocs_plugin_api::version_info::uses_acadrust_gate(
+ manifest.api_version,
+ ) {
+ true
+ } else {
+ match rustc_version.as_deref() {
+ None | Some("") => false,
+ Some(version) => ocs_plugin_api::version_info::rustc_versions_compatible(
+ version,
+ ocs_plugin_api::version_info::host_rustc_version(),
+ ),
+ }
+ };
Ok::<_, String>(ReleaseInfo {
tag: release.tag,
api_version: manifest.api_version,
acadrust_source,
acadrust_declared,
acadrust_compatible,
+ rustc_version,
+ rustc_declared,
+ rustc_compatible,
})
})();
match result {
@@ -251,8 +269,8 @@ pub fn fetch_release_info(repo: &str) -> Result, String> {
}
// Once a repo declares a fingerprint, require it on later API versions.
- let any_declared = info.iter().any(|r| r.acadrust_declared);
- if any_declared {
+ let any_acadrust_declared = info.iter().any(|r| r.acadrust_declared);
+ if any_acadrust_declared {
for r in &mut info {
if !r.acadrust_declared
&& ocs_plugin_api::version_info::uses_acadrust_gate(r.api_version)
@@ -261,7 +279,6 @@ pub fn fetch_release_info(repo: &str) -> Result, String> {
}
}
}
-
if info.is_empty() {
Err(last_error.unwrap_or_else(|| "no installable releases found".to_string()))
} else {
@@ -361,6 +378,24 @@ pub fn install(release: &Release, repository: &str) -> Result {
}
}
+ if ocs_plugin_api::version_info::uses_acadrust_gate(manifest.api_version) {
+ let Some(version) = manifest.rustc_version.as_deref() else {
+ return Err("Release has no rustc version; cannot verify ABI compatibility".to_string());
+ };
+ if version.is_empty() {
+ return Err("Release has no rustc version; cannot verify ABI compatibility".to_string());
+ }
+ if !ocs_plugin_api::version_info::rustc_versions_compatible(
+ version,
+ ocs_plugin_api::version_info::host_rustc_version(),
+ ) {
+ let host_rustc = ocs_plugin_api::version_info::host_rustc_version();
+ return Err(format!(
+ "Plugin built with {version}, host requires {host_rustc} - rebuild required"
+ ));
+ }
+ }
+
let dir = external::plugins_dir()
.ok_or("cannot locate the plugins folder")?
.join(&manifest.id);
diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs
index feeff457d..0a4d074cd 100644
--- a/src/scene/view/render.rs
+++ b/src/scene/view/render.rs
@@ -2802,7 +2802,7 @@ fn viewport_override(
.find_map(|(handle, value)| (handle == viewport).then_some(value))
}
-pub(in crate::scene) fn render_style_for_viewport(
+pub(crate) fn render_style_for_viewport(
document: &CadDocument,
e: &EntityType,
viewport: Option,
diff --git a/src/ui/icons.rs b/src/ui/icons.rs
index 52c3990cc..3ef4cfc90 100644
--- a/src/ui/icons.rs
+++ b/src/ui/icons.rs
@@ -346,13 +346,19 @@ fn semantic_color<'a>(
token: &[u8],
colors: &'a SemanticColors,
) -> Option<&'a [u8; 7]> {
- if is_one_of(token, &["#e0e0e0", "#eeeeee", "#ffffff", "#e1e1e1"]) {
+ if is_one_of(
+ token,
+ &["#b4b6b9", "#e0e0e0", "#eeeeee", "#ffffff", "#e1e1e1"],
+ ) {
Some(&colors.text)
} else if is_one_of(token, &["#cccccc", "#bdbdbd", "#aaaaaa"]) {
Some(&colors.secondary_strong)
} else if is_one_of(
token,
- &["#888888", "#888", "#9e9e9e", "#90a4ae", "#78909c", "#7a7a7a"],
+ &[
+ "#888888", "#888", "#9e9e9e", "#90a4ae", "#78909c", "#7a7a7a",
+ "#777777",
+ ],
) {
Some(&colors.secondary)
} else if is_one_of(
@@ -365,7 +371,10 @@ fn semantic_color<'a>(
Some(&colors.secondary_weak)
} else if is_one_of(token, &["#1a1a1a"]) {
Some(&colors.background)
- } else if is_one_of(token, &["#4cc9f0", "#4bc8f0", "#4a9eff", "#0099e5"]) {
+ } else if is_one_of(
+ token,
+ &["#6db7ed", "#4cc9f0", "#4bc8f0", "#4a9eff", "#0099e5"],
+ ) {
Some(&colors.primary)
} else if is_one_of(token, &["#1565c0"]) {
Some(&colors.primary_strong)
diff --git a/src/ui/popup/cycle_popup.rs b/src/ui/popup/cycle_popup.rs
index 080669842..9cbf9df46 100644
--- a/src/ui/popup/cycle_popup.rs
+++ b/src/ui/popup/cycle_popup.rs
@@ -1,26 +1,43 @@
//! Selection-cycling list box — shown at the cursor when a click lands on
-//! two or more overlapping objects. Each row names a candidate; clicking it
-//! adds that object to the current selection. Clicking outside dismisses it.
+//! two or more overlapping objects. Each row shows the object's drawn color
+//! swatch, its type, and (right-aligned) its layer; clicking a row adds that
+//! object to the current selection. Clicking outside dismisses it.
-use iced::widget::{button, column, container, mouse_area, opaque, text};
-use iced::{Element, Fill, Length};
+use iced::alignment::Horizontal;
+use iced::widget::{button, column, container, mouse_area, opaque, row, rule, space, text};
+use iced::{border::Radius, color, Element, Fill, Length, Theme};
use crate::app::Message;
+/// Row content for one overlapping candidate.
+pub struct CycleCandidate {
+ pub handle: acadrust::Handle,
+ pub type_name: String,
+ pub layer: String,
+ /// The color the object is drawn with (layer-inherited when ByLayer).
+ pub color: [f32; 4],
+}
+
/// Full-canvas overlay: the list box anchored at `anchor` (canvas
/// coordinates) plus a transparent click-catcher that cancels.
pub fn cycle_popup_overlay(
anchor: iced::Point,
- items: Vec<(acadrust::Handle, String)>,
+ items: Vec,
) -> Element<'static, Message> {
+ // The type column and the layer column get one shared width each (the
+ // widest entry of their kind, clamped) so the separators line up.
+ let type_w = clamp_col_width(items.iter().map(|c| c.type_name.chars().count()), 11.0, 40.0, 96.0);
+ let layer_w =
+ clamp_col_width(items.iter().map(|c| c.layer.chars().count()), 10.0, 28.0, 84.0);
+
let rows: Vec> = items
.into_iter()
- .map(|(handle, label)| item_row(handle, label))
+ .map(|c| item_row(c.handle, c.type_name, c.layer, c.color, type_w, layer_w))
.collect();
let panel = container(column(rows))
.style(container::bordered_box)
- .width(Length::Fixed(150.0));
+ .width(Length::Fixed(type_w + layer_w + 42.0));
let positioned = iced::widget::pin(opaque(panel))
.position(iced::Point::new(anchor.x.max(0.0), anchor.y.max(0.0)));
@@ -28,13 +45,63 @@ pub fn cycle_popup_overlay(
mouse_area(positioned).on_press(Message::CycleCancel).into()
}
-fn item_row(handle: acadrust::Handle, label: String) -> Element<'static, Message> {
- let content = text(label).size(11).align_y(iced::Center);
+/// Column width for the widest label, using a per-glyph estimate with a
+/// minimum so short labels don't collapse and a maximum so long ones clip.
+fn clamp_col_width(labels: impl Iterator- , glyph_w: f32, min: f32, max: f32) -> f32 {
+ let widest = labels.max().unwrap_or(0) as f32 * glyph_w;
+ widest.clamp(min, max)
+}
+
+fn item_row(
+ handle: acadrust::Handle,
+ type_name: String,
+ layer: String,
+ color: [f32; 4],
+ type_w: f32,
+ layer_w: f32,
+) -> Element<'static, Message> {
+ let swatch = container(
+ space::Space::new()
+ .width(Length::Fixed(9.0))
+ .height(Length::Fixed(9.0)),
+ )
+ .style(move |_| container::Style {
+ background: Some(iced::Background::Color(iced::Color::from_rgba(
+ color[0], color[1], color[2], color[3],
+ ))),
+ border: iced::Border {
+ color: color!(0x6b6b6b),
+ width: 1.0,
+ radius: Radius::from(2.0),
+ },
+ ..Default::default()
+ });
+
+ let content = row![
+ swatch,
+ container(text(type_name).size(11))
+ .width(Length::Fixed(type_w))
+ .align_y(iced::alignment::Vertical::Center),
+ container(rule::vertical(1)).height(Length::Fixed(12.0)),
+ container(
+ text(layer)
+ .size(10)
+ .align_x(Horizontal::Right)
+ .style(|theme: &Theme| iced::widget::text::Style {
+ color: Some(theme.palette().background.base.text.scale_alpha(0.72)),
+ })
+ )
+ .width(Length::Fixed(layer_w))
+ .align_y(iced::alignment::Vertical::Center),
+ ]
+ .spacing(6)
+ .align_y(iced::Center);
+
let btn = button(content)
.on_press(Message::CycleSelect(handle))
.style(button::subtle)
.width(Fill)
- .padding([4, 10]);
+ .padding([3, 8]);
// Highlight the underlying object while the cursor is over this row.
mouse_area(btn)
.on_enter(Message::CycleHover(Some(handle)))
diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs
index 96815f642..5b3308611 100644
--- a/src/ui/ribbon/mod.rs
+++ b/src/ui/ribbon/mod.rs
@@ -1303,6 +1303,20 @@ fn render_group<'a>(
let mut items_row: Vec> = Vec::new();
let mut small_buf: Vec> = Vec::new();
+ let ctx = widgets::RenderCtx {
+ active_tool,
+ open_dd,
+ last_cmd,
+ state,
+ layer_infos,
+ active_layer,
+ active_color,
+ active_linetype,
+ active_lineweight,
+ style_ctx,
+ compact,
+ };
+
for item in &group.tools {
let is_large = match item {
RibbonItem::LargeTool(_) | RibbonItem::LargeDropdown { .. } => !compact,
@@ -1314,20 +1328,7 @@ fn render_group<'a>(
if is_large {
flush_small_col(&mut small_buf, &mut items_row);
- items_row.push(render_large(
- item,
- active_tool,
- open_dd,
- last_cmd,
- state,
- layer_infos,
- active_layer,
- active_color,
- active_linetype,
- active_lineweight,
- style_ctx,
- compact,
- ));
+ items_row.push(render_large(item, &ctx));
} else {
small_buf.push(render_small(
item,
@@ -1467,34 +1468,42 @@ fn collapse_button<'a>(
// For a Properties panel the representative is its Match button.
let rep = representative(group, last_used);
let face: Element<'_, Message> = match rep {
- Some(RibbonItem::PropertiesGroup { match_prop }) => render_large(
- &RibbonItem::LargeTool(match_prop.clone()),
- active_tool,
- open_dd,
- last_cmd,
- state,
- layer_infos,
- active_layer,
- active_color,
- active_linetype,
- active_lineweight,
- style_ctx,
- false,
- ),
- Some(item) => render_large(
- item,
- active_tool,
- open_dd,
- last_cmd,
- state,
- layer_infos,
- active_layer,
- active_color,
- active_linetype,
- active_lineweight,
- style_ctx,
- false,
- ),
+ Some(RibbonItem::PropertiesGroup { match_prop }) => {
+ render_large(
+ &RibbonItem::LargeTool(match_prop.clone()),
+ &widgets::RenderCtx {
+ active_tool,
+ open_dd,
+ last_cmd,
+ state,
+ layer_infos,
+ active_layer,
+ active_color,
+ active_linetype,
+ active_lineweight,
+ style_ctx,
+ compact: false,
+ },
+ )
+ }
+ Some(item) => {
+ render_large(
+ item,
+ &widgets::RenderCtx {
+ active_tool,
+ open_dd,
+ last_cmd,
+ state,
+ layer_infos,
+ active_layer,
+ active_color,
+ active_linetype,
+ active_lineweight,
+ style_ctx,
+ compact: false,
+ },
+ )
+ }
None => text("").into(),
};
diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs
index 29e9f44a3..edbc651d0 100644
--- a/src/ui/ribbon/widgets.rs
+++ b/src/ui/ribbon/widgets.rs
@@ -70,6 +70,18 @@ pub(super) const TOOL_BAR_H: f32 = 3.0 * ROW_H + 18.0;
/// shorter than a full 3-row panel — the ribbon height follows it down.
pub(super) const COLLAPSED_FACE_H: f32 = LARGE_ICON + 20.0;
+/// Gap between the combo dropdown and the tool rows beneath it.
+const COMBO_ROW_SPACING: f32 = 3.0;
+/// Shared padding of combo-style panels (Layers, Properties, style selectors).
+/// The top inset pins every primary dropdown to the same baseline regardless
+/// of how many icon rows sit underneath it.
+const COMBO_PANEL_PAD: Padding = Padding {
+ top: 6.0,
+ bottom: 4.0,
+ left: 4.0,
+ right: 4.0,
+};
+
// ── Automatic large-button sizing ────────────────────────────────────────
thread_local! {
@@ -604,21 +616,40 @@ pub(super) fn render_small<'a>(
// ── Large item renderer ────────────────────────────────────────────────────
+/// Shared render context threaded through the large-item render path. Bundling
+/// the per-view state keeps new fields from rippling through every render
+/// signature and call site (same idea as `ToggleState`).
+#[derive(Clone, Copy)]
+pub(super) struct RenderCtx<'a> {
+ pub active_tool: &'a Option,
+ pub open_dd: &'a Option,
+ pub last_cmd: &'a HashMap<&'static str, &'static str>,
+ pub state: ToggleState,
+ pub layer_infos: &'a [LayerInfo],
+ pub active_layer: &'a str,
+ pub active_color: AcadColor,
+ pub active_linetype: &'a str,
+ pub active_lineweight: LineWeight,
+ pub style_ctx: &'a StyleContext,
+ /// When compact, the Properties panel's Match button shrinks to a small icon.
+ pub compact: bool,
+}
+
/// A large dropdown button: the current icon on top, its ▾ directly beneath the
/// icon, then the label at the bottom. Shared by `LargeDropdown` / `Dropdown` in
/// the full ribbon and by a collapsed panel whose representative is a dropdown.
/// `explicit_label` overrides the derived (current-item) label when given.
-#[allow(clippy::too_many_arguments)]
pub(super) fn render_large_dropdown<'a>(
id: &'static str,
icon: IconKind,
explicit_label: Option<&'a str>,
items: &[(&'static str, &'static str, IconKind)],
default: &'static str,
- active_tool: &Option,
- open_dd: &Option,
- last_cmd: &HashMap<&'static str, &'static str>,
+ ctx: &RenderCtx<'_>,
) -> Element<'a, Message> {
+ let active_tool = ctx.active_tool;
+ let open_dd = ctx.open_dd;
+ let last_cmd = ctx.last_cmd;
let active = active_tool.as_deref() == Some(id)
|| items
.iter()
@@ -708,22 +739,65 @@ pub(super) fn render_large_dropdown<'a>(
.into()
}
+/// A row of small tool buttons beneath a combo dropdown. Shared by the
+/// Layers, Properties and style-combo panels so their icon rows render
+/// identically.
+fn tool_row<'a>(tools: &[ToolDef], active_tool: &Option) -> Element<'a, Message> {
+ let btns: Vec> = tools
+ .iter()
+ .map(|t| {
+ let is_active = active_tool.as_deref() == Some(t.id);
+ let tip = t!(t.label);
+ let event = t.event.clone();
+ let icon_el: Element = make_icon(t.icon, 16.0);
+ let msg = module_event_to_message(event);
+ tooltip(
+ button(icon_el)
+ .on_press(msg)
+ .style(move |theme: &Theme, status| {
+ tool_btn_style(theme, is_active, status)
+ })
+ .padding([2, 5]),
+ make_tip(tip.to_string()),
+ TipPos::Right,
+ )
+ .gap(4.0)
+ .delay(Duration::from_millis(400))
+ .style(tip_style)
+ .into()
+ })
+ .collect();
+ row(btns).spacing(2).align_y(iced::Center).into()
+}
+
+/// Fixed-height wrapper for a combo-style panel: dropdown on top, icon rows
+/// below, all pinned to the panel's top edge so every panel's primary
+/// dropdown shares one baseline.
+fn combo_panel_col(width: f32, items: Vec>) -> Element<'_, Message> {
+ container(column(items).spacing(COMBO_ROW_SPACING).align_x(iced::Left))
+ .width(Length::Fixed(width))
+ .height(Fill)
+ .align_y(iced::Top)
+ .padding(COMBO_PANEL_PAD)
+ .into()
+}
+
/// Render a full-height large button (LargeTool, LargeDropdown, LayerCombo, StyleCombo).
pub(super) fn render_large<'a>(
item: &RibbonItem,
- active_tool: &Option,
- open_dd: &Option,
- last_cmd: &HashMap<&'static str, &'static str>,
- state: ToggleState,
- layer_infos: &'a [LayerInfo],
- active_layer: &'a str,
- active_color: AcadColor,
- active_linetype: &'a str,
- active_lineweight: LineWeight,
- style_ctx: &StyleContext,
- // When compact, the Properties panel's Match button shrinks to a small icon.
- compact: bool,
+ ctx: &RenderCtx<'_>,
) -> Element<'a, Message> {
+ let active_tool = ctx.active_tool;
+ let open_dd = ctx.open_dd;
+ let last_cmd = ctx.last_cmd;
+ let state = ctx.state;
+ let layer_infos = ctx.layer_infos;
+ let active_layer = ctx.active_layer;
+ let active_color = ctx.active_color;
+ let active_linetype = ctx.active_linetype;
+ let active_lineweight = ctx.active_lineweight;
+ let style_ctx = ctx.style_ctx;
+ let compact = ctx.compact;
match item {
// A plain Tool renders large too, so a collapsed panel can show its
// representative tool as a big icon.
@@ -779,16 +853,7 @@ pub(super) fn render_large<'a>(
items,
default,
} => {
- render_large_dropdown(
- *id,
- *icon,
- Some(*label),
- items,
- *default,
- active_tool,
- open_dd,
- last_cmd,
- )
+ render_large_dropdown(*id, *icon, Some(*label), items, *default, ctx)
}
// A plain Dropdown renders large too (used by a collapsed panel whose
@@ -799,9 +864,7 @@ pub(super) fn render_large<'a>(
items,
default,
} => {
- render_large_dropdown(
- *id, *icon, None, items, *default, active_tool, open_dd, last_cmd,
- )
+ render_large_dropdown(*id, *icon, None, items, *default, ctx)
}
RibbonItem::LayerComboGroup { row2, row3 } => {
@@ -868,56 +931,14 @@ pub(super) fn render_large<'a>(
.padding([3, 8])
.width(Fill);
- let make_tool_row = |tools: &[ToolDef]| -> Element {
- let btns: Vec> = tools
- .iter()
- .map(|t| {
- let is_active = active_tool.as_deref() == Some(t.id);
- let tip = t!(t.label);
- let event = t.event.clone();
- let icon_el: Element = make_icon(t.icon, 16.0);
- let msg = module_event_to_message(event);
- tooltip(
- button(icon_el)
- .on_press(msg)
- .style(move |theme: &Theme, status| {
- tool_btn_style(theme, is_active, status)
- })
- .padding([2, 5]),
- make_tip(tip.to_string()),
- TipPos::Right,
- )
- .gap(4.0)
- .delay(Duration::from_millis(400))
- .style(tip_style)
- .into()
- })
- .collect();
- row(btns).spacing(2).align_y(iced::Center).into()
- };
-
- let tools_row2 = make_tool_row(row2);
- let tools_row3 = make_tool_row(row3);
-
- container(
- column![
- PosReport::new(LAYER_COMBO_ID, combo_btn),
- tools_row2,
- tools_row3
- ]
- .spacing(3)
- .align_x(iced::Left),
+ combo_panel_col(
+ combo_w,
+ vec![
+ container(PosReport::new(LAYER_COMBO_ID, combo_btn)).width(Fill).into(),
+ tool_row(row2, active_tool),
+ tool_row(row3, active_tool),
+ ],
)
- .width(Length::Fixed(combo_w))
- .height(Fill)
- .align_y(iced::Center)
- .padding(Padding {
- top: 4.0,
- bottom: 4.0,
- left: 4.0,
- right: 4.0,
- })
- .into()
}
RibbonItem::PropertiesGroup { match_prop } => {
@@ -934,17 +955,10 @@ pub(super) fn render_large<'a>(
} else {
render_large(
&RibbonItem::LargeTool(match_prop.clone()),
- active_tool,
- open_dd,
- last_cmd,
- state,
- layer_infos,
- active_layer,
- active_color,
- active_linetype,
- active_lineweight,
- style_ctx,
- false,
+ &RenderCtx {
+ compact: false,
+ ..*ctx
+ },
)
};
@@ -1005,6 +1019,8 @@ pub(super) fn render_large<'a>(
);
let lw_row = prop_row(LwItem(active_lineweight).to_string(), PROP_LW_ID, None);
+ // Top-pinned like every combo panel, so the Color dropdown shares
+ // the same baseline as the Layers / style selectors.
let combos = container(
column![
PosReport::new(PROP_COLOR_ID, color_row),
@@ -1015,12 +1031,12 @@ pub(super) fn render_large<'a>(
.align_x(iced::Left),
)
.height(Fill)
- .align_y(iced::Center)
+ .align_y(iced::Top)
.padding(Padding {
- top: 4.0,
- bottom: 4.0,
+ top: COMBO_PANEL_PAD.top,
+ bottom: COMBO_PANEL_PAD.bottom,
left: 0.0,
- right: 4.0,
+ right: COMBO_PANEL_PAD.right,
});
row![mp_el, combos]
@@ -1068,54 +1084,15 @@ pub(super) fn render_large<'a>(
let items_panel: Element =
iced::widget::Space::new().width(0).height(0).into();
- // ── tool rows below combo ──
- let make_tool_row = |tools: &[ToolDef]| -> Element {
- let btns: Vec> = tools
- .iter()
- .map(|t| {
- let is_active = active_tool.as_deref() == Some(t.id);
- let tip = t!(t.label);
- let event = t.event.clone();
- let icon_el: Element = make_icon(t.icon, 16.0);
- let msg = module_event_to_message(event);
- tooltip(
- button(icon_el)
- .on_press(msg)
- .style(move |theme: &Theme, status| {
- tool_btn_style(theme, is_active, status)
- })
- .padding([2, 5]),
- make_tip(tip.to_string()),
- TipPos::Right,
- )
- .gap(4.0)
- .delay(Duration::from_millis(400))
- .style(tip_style)
- .into()
- })
- .collect();
- row(btns).spacing(2).align_y(iced::Center).into()
- };
-
let mut col_items: Vec> =
vec![container(row![PosReport::new(*combo_id, combo_btn), items_panel].spacing(0))
.width(Fill)
.into()];
for row_tools in rows {
- col_items.push(make_tool_row(row_tools));
+ col_items.push(tool_row(row_tools, active_tool));
}
- container(column(col_items).spacing(3).align_x(iced::Left))
- .width(Length::Fixed(STYLE_COMBO_W))
- .height(Fill)
- .align_y(iced::Center)
- .padding(Padding {
- top: 4.0,
- bottom: 4.0,
- left: 4.0,
- right: 4.0,
- })
- .into()
+ combo_panel_col(STYLE_COMBO_W, col_items)
}
}
}
diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs
index 6ecf88717..57bf389bf 100644
--- a/src/ui/window/block_palette.rs
+++ b/src/ui/window/block_palette.rs
@@ -6,7 +6,7 @@ use crate::modules::IconKind;
use crate::scene::model::wire_model::WireModel;
use crate::ui::dock::{DockMsg, PanelId};
use iced::widget::canvas::{Frame, Path, Program, Stroke};
-use iced::widget::{button, canvas, column, container, mouse_area, row, scrollable, svg, text, text_input, tooltip};
+use iced::widget::{button, canvas, column, container, mouse_area, row, scrollable, text, text_input, tooltip};
use iced::{Background, Border, Color, Element, Fill, Length, Theme};
const TOOL_H: f32 = 22.0;
@@ -355,10 +355,7 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'
fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message> {
let icon_el: Element<'_, Message> = match icon {
IconKind::Glyph(s) => text(s).size(15).color(Color::WHITE).into(),
- IconKind::Svg(bytes) => svg(svg::Handle::from_memory(bytes))
- .width(Length::Fixed(TOOL_H))
- .height(Length::Fixed(TOOL_H))
- .into(),
+ IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H),
};
button(icon_el)
.on_press(Message::BlockPalette(msg))
diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs
index 1b11c7354..341fc09a6 100644
--- a/src/ui/window/plugin_manager.rs
+++ b/src/ui/window/plugin_manager.rs
@@ -203,6 +203,7 @@ fn newest_update(installed: &str, releases: &[ReleaseInfo]) -> Option {
.filter(|release| {
ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version)
&& release.acadrust_compatible
+ && release.rustc_compatible
})
.filter_map(|release| {
semver::Version::parse(trim_version_prefix(&release.tag))
@@ -225,13 +226,15 @@ fn external_card<'a>(
) -> Element<'a, Message> {
let failed_old_api = load_error.is_some() && !p.api_compatible();
let acadrust_mismatch = p.acadrust_declared && !p.acadrust_compatible();
+ let rustc_mismatch = ocs_plugin_api::version_info::uses_acadrust_gate(p.api_version)
+ && !p.rustc_compatible();
let (status, kind) = if loaded && disabled {
(t!("Disabled"), StatusKind::Muted)
} else if loaded {
(t!("Loaded"), StatusKind::Success)
} else if !p.api_compatible() || failed_old_api {
(t!("API incompatible"), StatusKind::Danger)
- } else if acadrust_mismatch {
+ } else if acadrust_mismatch || rustc_mismatch {
(t!("Incompatible"), StatusKind::Danger)
} else if load_error.is_some() {
(t!("Load failed"), StatusKind::Danger)
@@ -375,7 +378,8 @@ fn install_controls<'a>(
let action = match selected_release {
Some(release)
if ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version)
- && release.acadrust_compatible =>
+ && release.acadrust_compatible
+ && release.rustc_compatible =>
{
pill_button(
t!("Install"),
@@ -990,6 +994,9 @@ mod tests {
acadrust_source: None,
acadrust_declared: false,
acadrust_compatible: true,
+ rustc_version: None,
+ rustc_declared: false,
+ rustc_compatible: true,
},
ReleaseInfo {
tag: "v2.0.0".to_string(),
@@ -997,6 +1004,9 @@ mod tests {
acadrust_source: None,
acadrust_declared: false,
acadrust_compatible: true,
+ rustc_version: None,
+ rustc_declared: false,
+ rustc_compatible: true,
},
ReleaseInfo {
tag: "v1.9.9".to_string(),
@@ -1004,6 +1014,9 @@ mod tests {
acadrust_source: None,
acadrust_declared: false,
acadrust_compatible: true,
+ rustc_version: None,
+ rustc_declared: false,
+ rustc_compatible: true,
},
];
assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v2.0.0"));
@@ -1021,6 +1034,9 @@ mod tests {
),
acadrust_declared: true,
acadrust_compatible: true,
+ rustc_version: None,
+ rustc_declared: false,
+ rustc_compatible: true,
},
ReleaseInfo {
tag: "v2.0.0".to_string(),
@@ -1030,6 +1046,9 @@ mod tests {
),
acadrust_declared: true,
acadrust_compatible: false,
+ rustc_version: None,
+ rustc_declared: false,
+ rustc_compatible: true,
},
];
assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v1.4.0"));