From eb53e445ab0daf12e45688cec999f2b37d3ced10 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 17 Jun 2026 15:50:52 +0300 Subject: [PATCH] refactor(plugin): drop built-in plugins, external-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 7 - Cargo.toml | 2 +- crates/ocs_example_plugin/Cargo.toml | 16 --- crates/ocs_example_plugin/README.md | 37 ------ crates/ocs_example_plugin/plugin.toml | 13 -- crates/ocs_example_plugin/src/lib.rs | 68 ---------- src/app/document.rs | 33 +---- src/app/plugin_host.rs | 36 +----- src/app/view.rs | 1 - src/modules/demo_plugin/dispatch.rs | 17 --- src/modules/demo_plugin/manifest.rs | 14 -- src/modules/demo_plugin/mod.rs | 62 --------- src/modules/demo_plugin/plugin.rs | 21 --- src/modules/demo_plugin/plugin.toml | 13 -- src/modules/demo_plugin/register.rs | 7 - src/modules/mod.rs | 1 - src/plugin/external.rs | 2 +- src/plugin/host.rs | 10 +- src/plugin/manifest.rs | 7 - src/plugin/mod.rs | 12 +- src/plugin/registry.rs | 177 +++++--------------------- src/ui/plugin_manager.rs | 94 ++++---------- 22 files changed, 65 insertions(+), 585 deletions(-) delete mode 100644 crates/ocs_example_plugin/Cargo.toml delete mode 100644 crates/ocs_example_plugin/README.md delete mode 100644 crates/ocs_example_plugin/plugin.toml delete mode 100644 crates/ocs_example_plugin/src/lib.rs delete mode 100644 src/modules/demo_plugin/dispatch.rs delete mode 100644 src/modules/demo_plugin/manifest.rs delete mode 100644 src/modules/demo_plugin/mod.rs delete mode 100644 src/modules/demo_plugin/plugin.rs delete mode 100644 src/modules/demo_plugin/plugin.toml delete mode 100644 src/modules/demo_plugin/register.rs delete mode 100644 src/plugin/manifest.rs diff --git a/Cargo.lock b/Cargo.lock index 82acaed26..b9d5c180c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 1a1981dbe..bf9fc0aa1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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). diff --git a/crates/ocs_example_plugin/Cargo.toml b/crates/ocs_example_plugin/Cargo.toml deleted file mode 100644 index 58ad70c61..000000000 --- a/crates/ocs_example_plugin/Cargo.toml +++ /dev/null @@ -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//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"] } diff --git a/crates/ocs_example_plugin/README.md b/crates/ocs_example_plugin/README.md deleted file mode 100644 index ddc333348..000000000 --- a/crates/ocs_example_plugin/README.md +++ /dev/null @@ -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: - -``` -/OpenCADStudio/plugins/opencad.example/ - plugin.toml - libocs_example_plugin.so # .dll on Windows, .dylib on macOS -``` - -`` 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). diff --git a/crates/ocs_example_plugin/plugin.toml b/crates/ocs_example_plugin/plugin.toml deleted file mode 100644 index c8f4609fc..000000000 --- a/crates/ocs_example_plugin/plugin.toml +++ /dev/null @@ -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 = [] diff --git a/crates/ocs_example_plugin/src/lib.rs b/crates/ocs_example_plugin/src/lib.rs deleted file mode 100644 index b43153521..000000000 --- a/crates/ocs_example_plugin/src/lib.rs +++ /dev/null @@ -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 { - 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 { - 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); diff --git a/src/app/document.rs b/src/app/document.rs index ca27813ee..c1895536a 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -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( - &self, - plugin_id: &'static str, - _type_id: TypeId, - ) -> Option<&T> { - self.plugin_state.get(plugin_id)?.downcast_ref::() - } - - pub(super) fn plugin_state_mut( - &mut self, - plugin_id: &'static str, - _type_id: TypeId, - ) -> Option<&mut T> { - self.plugin_state.get_mut(plugin_id)?.downcast_mut::() - } - - pub(super) fn ensure_plugin_state( - &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::() - .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. diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index d96d87264..58482daba 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -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 { - self.document().entities() - } - - pub fn entities_mut(&mut self) -> impl Iterator { - 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) { - self.app.tabs[self.tab].active_cmd = Some(cmd); - } - - pub fn plugin_state( - &self, - plugin_id: &'static str, - ) -> Option<&T> { - self.app.tabs[self.tab].plugin_state(plugin_id, TypeId::of::()) - } - - pub fn plugin_state_mut( - &mut self, - plugin_id: &'static str, - ) -> Option<&mut T> { - self.app.tabs[self.tab].plugin_state_mut(plugin_id, TypeId::of::()) - } - - pub fn ensure_plugin_state( - &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 diff --git a/src/app/view.rs b/src/app/view.rs index c7b37ff0a..ffd5220e4 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -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, diff --git a/src/modules/demo_plugin/dispatch.rs b/src/modules/demo_plugin/dispatch.rs deleted file mode 100644 index ddb2aab16..000000000 --- a/src/modules/demo_plugin/dispatch.rs +++ /dev/null @@ -1,17 +0,0 @@ -use crate::plugin::host::HostApi; - -pub fn handle(host: &mut dyn HostApi, cmd: &str) -> bool { - // "DP_IMPORT " 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, - } -} \ No newline at end of file diff --git a/src/modules/demo_plugin/manifest.rs b/src/modules/demo_plugin/manifest.rs deleted file mode 100644 index e8638212f..000000000 --- a/src/modules/demo_plugin/manifest.rs +++ /dev/null @@ -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_"], -}; \ No newline at end of file diff --git a/src/modules/demo_plugin/mod.rs b/src/modules/demo_plugin/mod.rs deleted file mode 100644 index 7e5ebd51c..000000000 --- a/src/modules/demo_plugin/mod.rs +++ /dev/null @@ -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 { - 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 " 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:?}"); - } -} \ No newline at end of file diff --git a/src/modules/demo_plugin/plugin.rs b/src/modules/demo_plugin/plugin.rs deleted file mode 100644 index 1b9ec5381..000000000 --- a/src/modules/demo_plugin/plugin.rs +++ /dev/null @@ -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 { - Box::new(super::DemoPluginModule) - } - - fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { - dispatch::handle(host, cmd) - } -} \ No newline at end of file diff --git a/src/modules/demo_plugin/plugin.toml b/src/modules/demo_plugin/plugin.toml deleted file mode 100644 index 16703b279..000000000 --- a/src/modules/demo_plugin/plugin.toml +++ /dev/null @@ -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 = [] \ No newline at end of file diff --git a/src/modules/demo_plugin/register.rs b/src/modules/demo_plugin/register.rs deleted file mode 100644 index e89aa2f2d..000000000 --- a/src/modules/demo_plugin/register.rs +++ /dev/null @@ -1,7 +0,0 @@ -use super::plugin::DemoPlugin; - -inventory::submit! { - crate::plugin::registry::PluginRegistration { - construct: || Box::new(DemoPlugin), - } -} \ No newline at end of file diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 0b665a98b..6dfbe33ed 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -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; diff --git a/src/plugin/external.rs b/src/plugin/external.rs index 0bad7127d..15816ab11 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -205,7 +205,7 @@ fn parse_string_array(s: &str) -> Vec { // ── 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 { diff --git a/src/plugin/host.rs b/src/plugin/host.rs index 44ea177c2..fa85313c8 100644 --- a/src/plugin/host.rs +++ b/src/plugin/host.rs @@ -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}; \ No newline at end of file +// 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; \ No newline at end of file diff --git a/src/plugin/manifest.rs b/src/plugin/manifest.rs deleted file mode 100644 index 54fa23388..000000000 --- a/src/plugin/manifest.rs +++ /dev/null @@ -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::*; diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 5c978139c..2578c8bee 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -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//` 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}; \ No newline at end of file +pub(crate) use registry::try_dispatch; \ No newline at end of file diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index 8b794f28b..e458b6a75 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -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, -} - -inventory::collect!(PluginRegistration); - -/// Construct every registered built-in plugin (once per process). -pub fn all_plugins() -> Vec> { - inventory::iter:: - .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> { 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, ) -> Vec> { let mut core = core_registry::all_modules(); - let mut addons: Vec<(i32, Box)> = 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)> = 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::>() - ); - } #[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::>() - ); - // 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 " " 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")); - } -} \ No newline at end of file +} diff --git a/src/ui/plugin_manager.rs b/src/ui/plugin_manager.rs index a3a18be1e..65c70f7e4 100644 --- a/src/ui/plugin_manager.rs +++ b/src/ui/plugin_manager.rs @@ -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, externals: &[ExternalPlugin], loaded: &FxHashSet, 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.