forked from 1b-agent/opencadstudio-upstream
refactor(plugin): drop built-in plugins, external-only
OpenCADStudio no longer bundles plugins — every add-on is an external cdylib loaded from the plugins folder or installed via the marketplace. - Remove the demo plugin and the in-tree ocs_example_plugin crate. - Remove the inventory-based built-in registration path (PluginRegistration, all_plugins, installed_manifests); registry now serves core ribbon tabs plus loaded external plugins, and try_dispatch routes only to externals. - Plugin Manager drops the 'compiled into this build' section; the enable/disable toggle moves onto loaded external cards. - Prune the now-dead HostSession/DocumentTab helpers and re-exports. Part of the #100 extensibility epic (phase 2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
020a3a4c84
commit
eb53e445ab
22 files changed
+65
-585
No files matched your search
Generated
-7
@@ -3625,13 +3625,6 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ocs_example_plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ocs_plugin_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ocs_plugin_api"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ edition = "2021"
|
||||
build = "build.rs"
|
||||
|
||||
[workspace]
|
||||
members = ["crates/ocs_plugin_api", "crates/ocs_example_plugin"]
|
||||
members = ["crates/ocs_plugin_api"]
|
||||
|
||||
# Embed the application icon into the Windows .exe so Explorer, the taskbar,
|
||||
# the Start-menu tile and file-association entries show it (issue #107).
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "ocs_example_plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Reference external add-on for Open CAD Studio — builds a cdylib the host loads at runtime."
|
||||
license = "GPL-3.0-only"
|
||||
|
||||
# A dynamically-loaded plugin is a C-ABI dynamic library. `cargo build -p
|
||||
# ocs_example_plugin` produces target/<profile>/libocs_example_plugin.so (or
|
||||
# .dll/.dylib); drop it beside a plugin.toml in the plugins folder.
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# The stable contract, with the runtime `host` surface (HostApi/BuiltinPlugin).
|
||||
ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] }
|
||||
@@ -1,37 +0,0 @@
|
||||
# ocs_example_plugin
|
||||
|
||||
Reference **dynamically-loaded** add-on for Open CAD Studio. It depends only on
|
||||
`ocs_plugin_api` (with the `host` feature) — never on the `OpenCADStudio`
|
||||
binary — so it shows the full surface an out-of-tree plugin targets: a
|
||||
`PluginManifest`, a `CadModule` ribbon tab, a `BuiltinPlugin` entry point, and
|
||||
the `export_plugin!` C-ABI export.
|
||||
|
||||
## Build & install
|
||||
|
||||
```sh
|
||||
cargo build -p ocs_example_plugin # → target/debug/libocs_example_plugin.so
|
||||
```
|
||||
|
||||
Copy the library and `plugin.toml` into a folder named after the plugin id under
|
||||
the user plugins directory:
|
||||
|
||||
```
|
||||
<config>/OpenCADStudio/plugins/opencad.example/
|
||||
plugin.toml
|
||||
libocs_example_plugin.so # .dll on Windows, .dylib on macOS
|
||||
```
|
||||
|
||||
`<config>` is `%APPDATA%` (Windows), `~/Library/Application Support` (macOS), or
|
||||
`$XDG_CONFIG_HOME` / `~/.config` (Linux).
|
||||
|
||||
Restart Open CAD Studio. The host loads the cdylib at startup (after checking
|
||||
`ocs_plugin_api_version`), adds the **Example** ribbon tab, and routes `EX_`
|
||||
commands to it. `PLUGINS` lists it under *External* as **Loaded**; run `EX_HELLO`
|
||||
to see it respond.
|
||||
|
||||
## Contract
|
||||
|
||||
- `ocs_plugin_api::export_plugin!(MyPlugin)` emits `ocs_plugin_api_version()` and
|
||||
`ocs_plugin_register()`.
|
||||
- The package must be built with the **same toolchain and `ocs_plugin_api`
|
||||
version** as the host (approach B — the version symbol enforces the latter).
|
||||
@@ -1,13 +0,0 @@
|
||||
[plugin]
|
||||
id = "opencad.example"
|
||||
name = "Example Plugin"
|
||||
version = "0.1.0"
|
||||
description = "Reference dynamically-loaded add-on"
|
||||
author = "Open CAD Studio contributors"
|
||||
license = "GPL-3.0-only"
|
||||
|
||||
[opencad]
|
||||
api_version = 1
|
||||
ribbon_order = 50
|
||||
command_prefixes = ["EX_"]
|
||||
xdata_apps = []
|
||||
@@ -1,68 +0,0 @@
|
||||
//! Reference external add-on, built as a `cdylib` the host loads at runtime.
|
||||
//!
|
||||
//! It depends only on `ocs_plugin_api` (with the `host` feature) — never on the
|
||||
//! `OpenCADStudio` binary — so it demonstrates the stable contract an
|
||||
//! out-of-tree plugin targets: a `PluginManifest`, a `CadModule` ribbon tab, a
|
||||
//! `BuiltinPlugin` entry point, and the `export_plugin!` C-ABI export.
|
||||
|
||||
use ocs_plugin_api::host::{BuiltinPlugin, HostApi};
|
||||
use ocs_plugin_api::manifest::{ApiVersion, PluginManifest};
|
||||
use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||
|
||||
static MANIFEST: PluginManifest = PluginManifest {
|
||||
id: "opencad.example",
|
||||
name: "Example Plugin",
|
||||
version: "0.1.0",
|
||||
description: "Reference dynamically-loaded add-on",
|
||||
api_version: ApiVersion::CURRENT,
|
||||
ribbon_order: 50,
|
||||
xdata_apps: &[],
|
||||
command_prefixes: &["EX_"],
|
||||
};
|
||||
|
||||
/// Ribbon tab for the example plugin.
|
||||
struct ExampleModule;
|
||||
|
||||
impl CadModule for ExampleModule {
|
||||
fn id(&self) -> &'static str {
|
||||
"example"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"Example"
|
||||
}
|
||||
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
|
||||
vec![RibbonGroup {
|
||||
title: "Demo",
|
||||
tools: vec![RibbonItem::LargeTool(ToolDef {
|
||||
id: "EX_HELLO",
|
||||
label: "Hello",
|
||||
icon: IconKind::Glyph("◆"),
|
||||
event: ModuleEvent::Command("EX_HELLO".to_string()),
|
||||
})],
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
/// The plugin entry point handed to the host.
|
||||
struct ExamplePlugin;
|
||||
|
||||
impl BuiltinPlugin for ExamplePlugin {
|
||||
fn manifest(&self) -> &'static PluginManifest {
|
||||
&MANIFEST
|
||||
}
|
||||
fn ribbon(&self) -> Box<dyn CadModule> {
|
||||
Box::new(ExampleModule)
|
||||
}
|
||||
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
|
||||
match cmd {
|
||||
"EX_HELLO" => {
|
||||
host.push_info("Hello from the external example plugin (cdylib loaded).");
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit the C-ABI symbols the host loader looks for.
|
||||
ocs_plugin_api::export_plugin!(ExamplePlugin);
|
||||
+1
-32
@@ -9,7 +9,7 @@ use crate::ui::{LayerPanel, PropertiesPanel};
|
||||
use acadrust::tables::Ucs;
|
||||
use acadrust::{CadDocument, Handle};
|
||||
use iced;
|
||||
use std::any::{Any, TypeId};
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -216,37 +216,6 @@ impl DocumentTab {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn plugin_state<T: Any + Send + Sync + 'static>(
|
||||
&self,
|
||||
plugin_id: &'static str,
|
||||
_type_id: TypeId,
|
||||
) -> Option<&T> {
|
||||
self.plugin_state.get(plugin_id)?.downcast_ref::<T>()
|
||||
}
|
||||
|
||||
pub(super) fn plugin_state_mut<T: Any + Send + Sync + 'static>(
|
||||
&mut self,
|
||||
plugin_id: &'static str,
|
||||
_type_id: TypeId,
|
||||
) -> Option<&mut T> {
|
||||
self.plugin_state.get_mut(plugin_id)?.downcast_mut::<T>()
|
||||
}
|
||||
|
||||
pub(super) fn ensure_plugin_state<T: Any + Send + Sync + 'static>(
|
||||
&mut self,
|
||||
plugin_id: &'static str,
|
||||
init: impl FnOnce() -> T,
|
||||
) -> &mut T {
|
||||
if !self.plugin_state.contains_key(plugin_id) {
|
||||
self.plugin_state.insert(plugin_id, Box::new(init()));
|
||||
}
|
||||
self.plugin_state
|
||||
.get_mut(plugin_id)
|
||||
.expect("just inserted")
|
||||
.downcast_mut::<T>()
|
||||
.expect("plugin_id type mismatch")
|
||||
}
|
||||
|
||||
/// Welcome / Start tab. Carries a dummy Scene so the rest of the app
|
||||
/// can read tab state uniformly; the viewport renderer detects
|
||||
/// `is_start` and shows a welcome page instead.
|
||||
|
||||
+1
-35
@@ -1,6 +1,6 @@
|
||||
// HostSession — plugin-facing API implemented inside `app` (private field access).
|
||||
|
||||
use std::any::{Any, TypeId};
|
||||
use std::any::Any;
|
||||
|
||||
use acadrust::tables::AppId;
|
||||
use acadrust::xdata::ExtendedDataRecord;
|
||||
@@ -8,7 +8,6 @@ use acadrust::{CadDocument, EntityType, Handle};
|
||||
use ocs_plugin_api::host::HostApi;
|
||||
|
||||
use super::OpenCADStudio;
|
||||
use crate::command::CadCommand;
|
||||
|
||||
/// Session adapter: one active document tab, command line, undo.
|
||||
pub(crate) struct HostSession<'a> {
|
||||
@@ -33,14 +32,6 @@ impl<'a> HostSession<'a> {
|
||||
&mut self.app.tabs[self.tab].scene.document
|
||||
}
|
||||
|
||||
pub fn entities(&self) -> impl Iterator<Item = &EntityType> {
|
||||
self.document().entities()
|
||||
}
|
||||
|
||||
pub fn entities_mut(&mut self) -> impl Iterator<Item = &mut EntityType> {
|
||||
self.document_mut().entities_mut()
|
||||
}
|
||||
|
||||
pub fn add_entity(&mut self, entity: EntityType) -> Handle {
|
||||
self.app.tabs[self.tab].scene.add_entity(entity)
|
||||
}
|
||||
@@ -141,31 +132,6 @@ impl<'a> HostSession<'a> {
|
||||
self.app.command_line.push_error(msg);
|
||||
}
|
||||
|
||||
pub fn set_active_command(&mut self, cmd: Box<dyn CadCommand>) {
|
||||
self.app.tabs[self.tab].active_cmd = Some(cmd);
|
||||
}
|
||||
|
||||
pub fn plugin_state<T: Any + Send + Sync + 'static>(
|
||||
&self,
|
||||
plugin_id: &'static str,
|
||||
) -> Option<&T> {
|
||||
self.app.tabs[self.tab].plugin_state(plugin_id, TypeId::of::<T>())
|
||||
}
|
||||
|
||||
pub fn plugin_state_mut<T: Any + Send + Sync + 'static>(
|
||||
&mut self,
|
||||
plugin_id: &'static str,
|
||||
) -> Option<&mut T> {
|
||||
self.app.tabs[self.tab].plugin_state_mut(plugin_id, TypeId::of::<T>())
|
||||
}
|
||||
|
||||
pub fn ensure_plugin_state<T: Any + Send + Sync + 'static>(
|
||||
&mut self,
|
||||
plugin_id: &'static str,
|
||||
init: impl FnOnce() -> T,
|
||||
) -> &mut T {
|
||||
self.app.tabs[self.tab].ensure_plugin_state(plugin_id, init)
|
||||
}
|
||||
}
|
||||
|
||||
/// The stable contract a plugin's `dispatch` sees. Each method forwards to the
|
||||
|
||||
@@ -1122,7 +1122,6 @@ impl OpenCADStudio {
|
||||
}
|
||||
super::ModalKind::PluginManager => sized(
|
||||
crate::ui::plugin_manager::view_window(
|
||||
&crate::plugin::installed_manifests(),
|
||||
&self.disabled_plugins,
|
||||
&self.external_plugins,
|
||||
&self.loaded_plugin_ids,
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
use crate::plugin::host::HostApi;
|
||||
|
||||
pub fn handle(host: &mut dyn HostApi, cmd: &str) -> bool {
|
||||
// "DP_IMPORT <path>" arrives from ModuleEvent::PluginFileDialog with the
|
||||
// path in its original case (the command line is bypassed).
|
||||
if let Some(path) = cmd.strip_prefix("DP_IMPORT ") {
|
||||
host.push_info(&format!("demo_plugin imported: {path}"));
|
||||
return true;
|
||||
}
|
||||
match cmd {
|
||||
"DP_HELLO" => {
|
||||
host.push_info("Hello from demo_plugin (plugin host OK).");
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
use crate::plugin::manifest::{ApiVersion, PluginManifest};
|
||||
|
||||
pub const PLUGIN_ID: &str = "opencad.demo_plugin";
|
||||
|
||||
pub static MANIFEST: PluginManifest = PluginManifest {
|
||||
id: PLUGIN_ID,
|
||||
name: "Demo Plugin",
|
||||
version: "0.1.0",
|
||||
description: "Minimal add-on for plugin-host integration tests",
|
||||
api_version: ApiVersion::CURRENT,
|
||||
ribbon_order: 99,
|
||||
xdata_apps: &[],
|
||||
command_prefixes: &["DP_"],
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
// Minimal in-tree add-on — validates plugin host on `feature/plugin-host`.
|
||||
|
||||
pub mod dispatch;
|
||||
pub mod manifest;
|
||||
pub mod plugin;
|
||||
pub mod register;
|
||||
|
||||
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["DP_HELLO", "DP_IMPORT"]
|
||||
});
|
||||
|
||||
pub struct DemoPluginModule;
|
||||
|
||||
impl CadModule for DemoPluginModule {
|
||||
fn id(&self) -> &'static str {
|
||||
"demo_plugin"
|
||||
}
|
||||
|
||||
fn title(&self) -> &'static str {
|
||||
"Demo Plugin"
|
||||
}
|
||||
|
||||
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
|
||||
vec![RibbonGroup {
|
||||
title: "Smoke",
|
||||
tools: vec![
|
||||
RibbonItem::LargeTool(ToolDef {
|
||||
id: "DP_HELLO",
|
||||
label: "Hello",
|
||||
icon: IconKind::Glyph("★"),
|
||||
event: ModuleEvent::Command("DP_HELLO".to_string()),
|
||||
}),
|
||||
// Exercises ModuleEvent::PluginFileDialog: the host opens a
|
||||
// native picker and dispatches "DP_IMPORT <path>" back here.
|
||||
RibbonItem::LargeTool(ToolDef {
|
||||
id: "DP_IMPORT",
|
||||
label: "Import",
|
||||
icon: IconKind::Glyph("📂"),
|
||||
event: ModuleEvent::PluginFileDialog {
|
||||
command: "DP_IMPORT".to_string(),
|
||||
title: "Import Demo File".to_string(),
|
||||
filter_name: "Text".to_string(),
|
||||
extensions: vec!["txt".to_string(), "csv".to_string()],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::plugin::all_ribbon_modules;
|
||||
|
||||
#[test]
|
||||
fn ribbon_tab_is_registered() {
|
||||
let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect();
|
||||
assert!(titles.contains(&"Demo Plugin"), "tabs: {titles:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
use crate::plugin::host::{BuiltinPlugin, HostApi};
|
||||
use crate::plugin::manifest::PluginManifest;
|
||||
|
||||
use super::dispatch;
|
||||
use super::manifest;
|
||||
|
||||
pub struct DemoPlugin;
|
||||
|
||||
impl BuiltinPlugin for DemoPlugin {
|
||||
fn manifest(&self) -> &'static PluginManifest {
|
||||
&manifest::MANIFEST
|
||||
}
|
||||
|
||||
fn ribbon(&self) -> Box<dyn crate::modules::CadModule> {
|
||||
Box::new(super::DemoPluginModule)
|
||||
}
|
||||
|
||||
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
|
||||
dispatch::handle(host, cmd)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
[plugin]
|
||||
id = "opencad.demo_plugin"
|
||||
name = "Demo Plugin"
|
||||
version = "0.1.0"
|
||||
description = "Minimal add-on for plugin-host integration tests"
|
||||
author = "Open CAD Studio contributors"
|
||||
license = "GPL-3.0-only"
|
||||
|
||||
[opencad]
|
||||
api_version = 1
|
||||
ribbon_order = 99
|
||||
command_prefixes = ["DP_"]
|
||||
xdata_apps = []
|
||||
@@ -1,7 +0,0 @@
|
||||
use super::plugin::DemoPlugin;
|
||||
|
||||
inventory::submit! {
|
||||
crate::plugin::registry::PluginRegistration {
|
||||
construct: || Box::new(DemoPlugin),
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ pub mod draw;
|
||||
pub mod insert;
|
||||
pub mod model;
|
||||
pub mod layout;
|
||||
pub mod demo_plugin;
|
||||
pub mod manage;
|
||||
pub mod view;
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ fn parse_string_array(s: &str) -> Vec<String> {
|
||||
// ── Runtime loading (desktop only) ──────────────────────────────────────────
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use loader::{load, load_at_startup, loaded_ids, with_loaded, LoadedPlugin};
|
||||
pub use loader::{load_at_startup, loaded_ids, with_loaded};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod loader {
|
||||
|
||||
+4
-6
@@ -1,9 +1,7 @@
|
||||
// Plugin traits — HostSession lives in `app::plugin_host` (same-crate field
|
||||
// access) and implements the stable `HostApi` contract plugins target.
|
||||
|
||||
pub(crate) use crate::app::plugin_host::HostSession;
|
||||
/// The stable contract types a plugin targets. `BuiltinPlugin` (the package
|
||||
/// entry point) and `HostApi` (the runtime surface its `dispatch` receives)
|
||||
/// both live in `ocs_plugin_api` so in-tree and out-of-tree add-ons implement
|
||||
/// the same trait. See `docs/plugin-architecture.md`.
|
||||
pub use ocs_plugin_api::host::{BuiltinPlugin, HostApi};
|
||||
// The session adapter the registry wraps `app` in to dispatch to plugins. The
|
||||
// plugin-facing contract — `BuiltinPlugin` + `HostApi` — lives in
|
||||
// `ocs_plugin_api`; external cdylibs target it directly.
|
||||
pub(crate) use crate::app::plugin_host::HostSession;
|
||||
@@ -1,7 +0,0 @@
|
||||
//! Plugin identity and capability declaration.
|
||||
//!
|
||||
//! These types now live in the standalone, dependency-free `ocs_plugin_api`
|
||||
//! crate so external add-ons can target a semver-stable contract. Re-exported
|
||||
//! here to keep the `crate::plugin::manifest::*` path stable for in-tree use.
|
||||
|
||||
pub use ocs_plugin_api::manifest::*;
|
||||
+4
-8
@@ -1,15 +1,11 @@
|
||||
// Open CAD Studio plugin runtime (phase 1: built-in, in-process).
|
||||
//
|
||||
// Generic host only — no domain logic. See `docs/plugin-architecture.md`.
|
||||
// Domain plugins (e.g. storm_sewer) live under `src/modules/<name>/` and
|
||||
// register here via `inventory::submit!(PluginRegistration { … })`.
|
||||
// Open CAD Studio plugin runtime. Plugins are external cdylibs loaded from the
|
||||
// user plugins folder (see `external`) and installed via the marketplace; the
|
||||
// host ships no built-in add-ons. See `docs/plugin-architecture.md`.
|
||||
|
||||
pub mod external;
|
||||
pub mod host;
|
||||
pub mod manifest;
|
||||
pub mod marketplace;
|
||||
pub mod registry;
|
||||
|
||||
pub use registry::{all_ribbon_modules, ribbon_modules_enabled};
|
||||
pub(crate) use host::BuiltinPlugin;
|
||||
pub(crate) use registry::{installed_manifests, try_dispatch};
|
||||
pub(crate) use registry::try_dispatch;
|
||||
+30
-147
@@ -1,84 +1,47 @@
|
||||
// Compile-time plugin registry via `inventory`.
|
||||
// Plugin registry — external (dynamically-loaded) plugins only. OpenCADStudio
|
||||
// ships no built-in add-ons; every plugin is a cdylib loaded from the plugins
|
||||
// folder at startup (see `external`) and the marketplace installs them there.
|
||||
|
||||
use super::host::{BuiltinPlugin, HostSession};
|
||||
use super::manifest::PluginManifest;
|
||||
use crate::app::OpenCADStudio;
|
||||
use crate::modules::{registry as core_registry, CadModule};
|
||||
|
||||
pub struct PluginRegistration {
|
||||
pub construct: fn() -> Box<dyn BuiltinPlugin>,
|
||||
}
|
||||
|
||||
inventory::collect!(PluginRegistration);
|
||||
|
||||
/// Construct every registered built-in plugin (once per process).
|
||||
pub fn all_plugins() -> Vec<Box<dyn BuiltinPlugin>> {
|
||||
inventory::iter::<PluginRegistration>
|
||||
.into_iter()
|
||||
.map(|r| (r.construct)())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Static manifest of every installed add-on, sorted by `ribbon_order` then id
|
||||
/// for a stable display. Used by the plugin manager window.
|
||||
pub fn installed_manifests() -> Vec<&'static PluginManifest> {
|
||||
let mut manifests: Vec<&'static PluginManifest> =
|
||||
all_plugins().iter().map(|p| p.manifest()).collect();
|
||||
manifests.sort_by(|a, b| a.ribbon_order.cmp(&b.ribbon_order).then(a.id.cmp(b.id)));
|
||||
manifests
|
||||
}
|
||||
|
||||
/// Core ribbon tabs plus *every* add-on tab (sorted by `manifest.ribbon_order`).
|
||||
/// Core ribbon tabs plus every loaded external add-on tab.
|
||||
pub fn all_ribbon_modules() -> Vec<Box<dyn CadModule>> {
|
||||
ribbon_modules_enabled(&rustc_hash::FxHashSet::default())
|
||||
}
|
||||
|
||||
/// Core ribbon tabs plus add-on tabs whose plugin id is **not** in `disabled`
|
||||
/// (sorted by `manifest.ribbon_order`). Used by the Plugin Manager toggle.
|
||||
/// Core ribbon tabs plus the tabs of loaded external plugins whose id is **not**
|
||||
/// in `disabled` (sorted by `manifest.ribbon_order`).
|
||||
pub fn ribbon_modules_enabled(
|
||||
disabled: &rustc_hash::FxHashSet<String>,
|
||||
) -> Vec<Box<dyn CadModule>> {
|
||||
let mut core = core_registry::all_modules();
|
||||
let mut addons: Vec<(i32, Box<dyn CadModule>)> = all_plugins()
|
||||
.into_iter()
|
||||
.filter(|p| !disabled.contains(p.manifest().id))
|
||||
.map(|p| (p.manifest().ribbon_order, p.ribbon()))
|
||||
.collect();
|
||||
// Dynamically-loaded external plugins contribute tabs too (their libraries
|
||||
// stay resident for the session, so these vtables remain valid).
|
||||
// Dynamically-loaded external plugins contribute tabs (their libraries stay
|
||||
// resident for the session, so these vtables remain valid).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::plugin::external::with_loaded(|loaded| {
|
||||
for lp in loaded {
|
||||
if !disabled.contains(lp.id.as_str()) {
|
||||
addons.push((lp.plugin().manifest().ribbon_order, lp.plugin().ribbon()));
|
||||
{
|
||||
let mut addons: Vec<(i32, Box<dyn CadModule>)> = Vec::new();
|
||||
crate::plugin::external::with_loaded(|loaded| {
|
||||
for lp in loaded {
|
||||
if !disabled.contains(lp.id.as_str()) {
|
||||
addons.push((lp.plugin().manifest().ribbon_order, lp.plugin().ribbon()));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
addons.sort_by_key(|(order, _)| *order);
|
||||
core.extend(addons.into_iter().map(|(_, ribbon)| ribbon));
|
||||
});
|
||||
addons.sort_by_key(|(order, _)| *order);
|
||||
core.extend(addons.into_iter().map(|(_, ribbon)| ribbon));
|
||||
}
|
||||
let _ = disabled;
|
||||
core
|
||||
}
|
||||
|
||||
/// Try each *enabled* plugin until one handles `cmd`. Returns true if handled.
|
||||
/// Disabled plugins (toggled off in the Plugin Manager) are skipped.
|
||||
/// Dispatch `cmd` to a loaded external plugin (skipping disabled ones).
|
||||
/// Returns true if one handled it.
|
||||
pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bool {
|
||||
let disabled = app.disabled_plugin_ids();
|
||||
{
|
||||
let mut host = HostSession::new(app, tab);
|
||||
for plugin in all_plugins() {
|
||||
if disabled.contains(plugin.manifest().id) {
|
||||
continue;
|
||||
}
|
||||
if plugin.dispatch(&mut host, cmd) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Then the dynamically-loaded external plugins. The store is a separate
|
||||
// borrow from `app`, so wrapping `app` in a fresh `HostSession` here is
|
||||
// sound.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use super::host::HostSession;
|
||||
let disabled = app.disabled_plugin_ids();
|
||||
let handled = crate::plugin::external::with_loaded(|loaded| {
|
||||
let mut host = HostSession::new(app, tab);
|
||||
for lp in loaded {
|
||||
@@ -95,100 +58,20 @@ pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bo
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let _ = (app, tab, cmd);
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::OpenCADStudio;
|
||||
#[test]
|
||||
fn discovers_registered_plugins() {
|
||||
let plugins = all_plugins();
|
||||
assert!(
|
||||
!plugins.is_empty(),
|
||||
"expected at least one PluginRegistration (demo_plugin)"
|
||||
);
|
||||
assert!(
|
||||
plugins
|
||||
.iter()
|
||||
.any(|p| p.manifest().id == "opencad.demo_plugin"),
|
||||
"demo_plugin missing; ids: {:?}",
|
||||
plugins.iter().map(|p| p.manifest().id).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_manifests_lists_demo_plugin() {
|
||||
let manifests = installed_manifests();
|
||||
assert!(
|
||||
manifests.iter().any(|m| m.id == "opencad.demo_plugin"),
|
||||
"ids: {:?}",
|
||||
manifests.iter().map(|m| m.id).collect::<Vec<_>>()
|
||||
);
|
||||
// Sorted by (ribbon_order, id) — verify non-decreasing order.
|
||||
let mut prev: Option<(i32, &str)> = None;
|
||||
for m in &manifests {
|
||||
let key = (m.ribbon_order, m.id);
|
||||
if let Some(p) = prev {
|
||||
assert!(p <= key, "manifests not sorted: {p:?} then {key:?}");
|
||||
}
|
||||
prev = Some(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addon_ribbon_tabs_merge_after_core() {
|
||||
fn ribbon_is_core_only_without_external_plugins() {
|
||||
// No external plugins are loaded under test, so the ribbon is exactly
|
||||
// the built-in core tabs.
|
||||
let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect();
|
||||
assert!(titles.contains(&"Demo Plugin"), "ribbon tabs: {titles:?}");
|
||||
let core = core_registry::all_modules();
|
||||
assert_eq!(titles.len(), core.len() + all_plugins().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_plugin_drops_its_ribbon_tab() {
|
||||
let mut disabled = rustc_hash::FxHashSet::default();
|
||||
disabled.insert("opencad.demo_plugin".to_string());
|
||||
let titles: Vec<&str> = ribbon_modules_enabled(&disabled)
|
||||
.iter()
|
||||
.map(|m| m.title())
|
||||
.collect();
|
||||
assert!(
|
||||
!titles.contains(&"Demo Plugin"),
|
||||
"disabled plugin still present: {titles:?}"
|
||||
);
|
||||
// Only the add-on tab is dropped; core tabs stay.
|
||||
assert!(!titles.is_empty(), "expected core ribbon tabs");
|
||||
assert_eq!(titles.len(), core_registry::all_modules().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_dispatch_routes_demo_command() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
assert!(try_dispatch(&mut app, 0, "DP_HELLO"));
|
||||
let info = app.command_history_info();
|
||||
assert!(
|
||||
info.iter().any(|t| t.contains("demo_plugin") && t.contains("plugin host OK")),
|
||||
"info history: {info:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_file_dialog_dispatch_preserves_case() {
|
||||
// ModuleEvent::PluginFileDialog dispatches "<command> <path>" verbatim;
|
||||
// the mixed-case path must reach the plugin unaltered.
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let line = "DP_IMPORT /home/User/My Points.CSV";
|
||||
assert!(try_dispatch(&mut app, 0, line));
|
||||
let info = app.command_history_info();
|
||||
assert!(
|
||||
info.iter().any(|t| t.contains("/home/User/My Points.CSV")),
|
||||
"path case not preserved; info: {info:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_plugin_command_falls_through() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
assert!(!try_dispatch(&mut app, 0, "DP_NOPE"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-71
@@ -6,7 +6,6 @@
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::plugin::external::{ExternalPlugin, RegistryEntry};
|
||||
use crate::plugin::manifest::PluginManifest;
|
||||
use iced::widget::{button, column, container, pick_list, row, scrollable, text, text_input, Space};
|
||||
use iced::{Background, Border, Color, Element, Fill, Theme};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
@@ -109,48 +108,6 @@ fn toggle_button<'a>(id: &str, disabled: bool) -> Element<'a, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn plugin_card<'a>(m: &PluginManifest, disabled: bool) -> Element<'a, Message> {
|
||||
let mut header = row![text(m.name.to_string()).size(15).color(WHITE)];
|
||||
if disabled {
|
||||
header = header.push(Space::new().width(8));
|
||||
header = header.push(badge("Disabled".to_string()));
|
||||
}
|
||||
let header = header
|
||||
.push(Space::new().width(Fill))
|
||||
.push(badge(format!("v{}", m.version)))
|
||||
.push(Space::new().width(8))
|
||||
.push(badge(format!("API {}", m.api_version.major)))
|
||||
.push(Space::new().width(10))
|
||||
.push(toggle_button(m.id, disabled))
|
||||
.align_y(iced::Center);
|
||||
|
||||
let id_line = text(m.id.to_string()).size(11).color(ACCENT);
|
||||
let desc = text(m.description.to_string()).size(12).color(DIM);
|
||||
|
||||
let mut body = column![header, id_line, desc].spacing(5);
|
||||
|
||||
if !m.command_prefixes.is_empty() {
|
||||
body = body.push(
|
||||
text(format!("Commands: {}", m.command_prefixes.join(", ")))
|
||||
.size(11)
|
||||
.color(DIM),
|
||||
);
|
||||
}
|
||||
|
||||
container(body.padding([12, 14]))
|
||||
.width(Fill)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(CARD)),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Coloured status pill for a discovered external package.
|
||||
fn status_badge<'a>(label: &str, color: Color) -> Element<'a, Message> {
|
||||
container(text(label.to_string()).size(11).color(WHITE))
|
||||
@@ -166,8 +123,10 @@ fn status_badge<'a>(label: &str, color: Color) -> Element<'a, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn external_card<'a>(p: &ExternalPlugin, loaded: bool) -> Element<'a, Message> {
|
||||
let (status, color) = if loaded {
|
||||
fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Element<'a, Message> {
|
||||
let (status, color) = if loaded && disabled {
|
||||
("Disabled", Color { r: 0.45, g: 0.45, b: 0.45, a: 1.0 })
|
||||
} else if loaded {
|
||||
("Loaded", Color { r: 0.2, g: 0.5, b: 0.3, a: 1.0 })
|
||||
} else if !p.api_compatible() {
|
||||
("API incompatible", Color { r: 0.55, g: 0.28, b: 0.28, a: 1.0 })
|
||||
@@ -176,7 +135,7 @@ fn external_card<'a>(p: &ExternalPlugin, loaded: bool) -> Element<'a, Message> {
|
||||
} else {
|
||||
("Restart to load", Color { r: 0.5, g: 0.42, b: 0.2, a: 1.0 })
|
||||
};
|
||||
let header = row![
|
||||
let mut header = row![
|
||||
text(p.name.clone()).size(15).color(WHITE),
|
||||
Space::new().width(8),
|
||||
badge(format!("v{}", p.version)),
|
||||
@@ -186,6 +145,11 @@ fn external_card<'a>(p: &ExternalPlugin, loaded: bool) -> Element<'a, Message> {
|
||||
status_badge(status, color),
|
||||
]
|
||||
.align_y(iced::Center);
|
||||
// A loaded plugin can be turned off (drops its ribbon tab + dispatch).
|
||||
if loaded {
|
||||
header = header.push(Space::new().width(10));
|
||||
header = header.push(toggle_button(&p.id, disabled));
|
||||
}
|
||||
|
||||
let id_line = text(p.id.clone()).size(11).color(ACCENT);
|
||||
let mut body = column![header, id_line].spacing(5);
|
||||
@@ -329,40 +293,28 @@ fn marketplace_section<'a>(m: &MarketView) -> Element<'a, Message> {
|
||||
}
|
||||
|
||||
pub fn view_window<'a>(
|
||||
plugins: &[&'static PluginManifest],
|
||||
disabled: &FxHashSet<String>,
|
||||
externals: &[ExternalPlugin],
|
||||
loaded: &FxHashSet<String>,
|
||||
market: MarketView,
|
||||
) -> Element<'a, Message> {
|
||||
let title = text("Installed Plugins").size(20).color(WHITE);
|
||||
let subtitle = text(format!(
|
||||
"{} add-on{} compiled into this build",
|
||||
plugins.len(),
|
||||
if plugins.len() == 1 { "" } else { "s" }
|
||||
))
|
||||
.size(12)
|
||||
.color(DIM);
|
||||
let title = text("Plugins").size(20).color(WHITE);
|
||||
let subtitle = text("Add-ons load from the plugins folder. Install from a repository below.")
|
||||
.size(12)
|
||||
.color(DIM);
|
||||
|
||||
let mut list = column![].spacing(10);
|
||||
if plugins.is_empty() {
|
||||
list = list.push(text("No built-in plugins.").size(13).color(DIM));
|
||||
// Installed external packages (from the plugins folder).
|
||||
if externals.is_empty() {
|
||||
list = list.push(text("No plugins installed yet.").size(13).color(DIM));
|
||||
} else {
|
||||
for m in plugins {
|
||||
list = list.push(plugin_card(m, disabled.contains(m.id)));
|
||||
}
|
||||
}
|
||||
// External (plugins-folder) packages — discovered, not yet loaded.
|
||||
if !externals.is_empty() {
|
||||
list = list.push(Space::new().height(10));
|
||||
list = list.push(text("External (plugins folder)").size(13).color(ACCENT));
|
||||
list = list.push(
|
||||
text("Detected on disk. Dynamic loading is not active yet.")
|
||||
.size(11)
|
||||
.color(DIM),
|
||||
);
|
||||
list = list.push(text("Installed").size(13).color(ACCENT));
|
||||
for p in externals {
|
||||
list = list.push(external_card(p, loaded.contains(&p.id)));
|
||||
list = list.push(external_card(
|
||||
p,
|
||||
loaded.contains(&p.id),
|
||||
disabled.contains(&p.id),
|
||||
));
|
||||
}
|
||||
}
|
||||
// Marketplace: install from a linked repository's releases.
|
||||
|
||||
Reference in new issue
Block a user