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:
Hakan SevenandClaude Opus 4.8 committed 2026-06-17 15:50:52 +03:00
1 parent 020a3a4c84
commit eb53e445ab
22 files changed
+65 -585

No files matched your search

Generated
-7
View File
@@ -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
View File
@@ -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).
-16
View File
@@ -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"] }
-37
View File
@@ -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).
-13
View File
@@ -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 = []
-68
View File
@@ -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
View File
@@ -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
View File
@@ -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
-1
View File
@@ -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,
-17
View File
@@ -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,
}
}
-14
View File
@@ -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_"],
};
-62
View File
@@ -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:?}");
}
}
-21
View File
@@ -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)
}
}
-13
View File
@@ -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 = []
-7
View File
@@ -1,7 +0,0 @@
use super::plugin::DemoPlugin;
inventory::submit! {
crate::plugin::registry::PluginRegistration {
construct: || Box::new(DemoPlugin),
}
}
-1
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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;
-7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.