diff --git a/docs/PR-plugin-host.md b/docs/PR-plugin-host.md index f6137d7e..ebb6611c 100644 --- a/docs/PR-plugin-host.md +++ b/docs/PR-plugin-host.md @@ -51,7 +51,7 @@ cargo build cargo test --lib ``` -No domain plugin is registered in this branch; existing core tests should pass unchanged. +A minimal **`demo_plugin`** add-on registers at compile time for smoke tests (`DP_HELLO` command, Demo Plugin ribbon tab). Remove or gate it before release if maintainers prefer zero in-tree add-ons. ## Review questions diff --git a/docs/issue-78-comment.txt b/docs/issue-78-comment.txt new file mode 100644 index 00000000..0be48a3a --- /dev/null +++ b/docs/issue-78-comment.txt @@ -0,0 +1,36 @@ +@HakanSeven12 @schoeller — following up with a concrete architecture proposal and an implementation ready for review. + +### Proposal + +I've drafted a **QGIS-style add-on model** on my fork: + +- **Spec:** [docs/plugin-architecture.md](https://github.com/mf4633/OpenCADStudio/blob/feature/plugin-host/docs/plugin-architecture.md) +- **Scaffold:** [docs/plugin-template/](https://github.com/mf4633/OpenCADStudio/tree/feature/plugin-host/docs/plugin-template) +- **Framework PR:** #80 — **host only, no Storm Sewer in core** + +**Three layers:** host core → add-on package (plugin.toml, ribbon, commands) → optional headless engine crate. Domain data lives on DWG entities (XDATA), not a proprietary project DB. + +**Phase 1 (PR #80):** in-process plugins via inventory::submit!(PluginRegistration), HostSession API, per-document plugin state, command routing without editing commands.rs. + +**Phase 2:** user install folder + dynamic .dll/.so with the same plugin.toml. + +### Storm Sewer (separate) + +Storm Sewer stays on a separate branch as the reference consumer — not in core: [feature/storm-sewer-module](https://github.com/mf4633/OpenCADStudio/tree/feature/storm-sewer-module). + +### Re: script languages (@schoeller, #29) + +Agree this shouldn't be either/or. Suggested sequencing: + +1. Native Rust add-ons + stable HostSession / ocs_plugin_api +2. Python (or similar) as **bindings over that same API** — one extension surface, two authoring paths + +Phase 1 defers embedded scripting until the native API is stable. + +### Questions for maintainers + +1. ocs_plugin_api as a workspace crate with semver — OK? +2. Should the main repo ship **zero** discipline modules, or optional built-ins for dev? +3. Priority: extract API crate (1b) vs dynamic loading (2)? + +— Michael diff --git a/src/app/mod.rs b/src/app/mod.rs index 6d2ee32f..17981d37 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1475,6 +1475,22 @@ impl OpenCADStudio { app } + #[cfg(test)] + pub(crate) fn new_for_test() -> Self { + Self::new() + } + + #[cfg(test)] + pub(crate) fn command_history_info(&self) -> Vec { + use crate::ui::command_line::EntryKind; + self.command_line + .history + .iter() + .filter(|e| e.kind == EntryKind::Info) + .map(|e| e.text.clone()) + .collect() + } + /// Boot function for `iced::daemon`: returns initial state plus a task that /// opens the primary application window. fn boot() -> (Self, Task) { diff --git a/src/modules/demo_plugin/dispatch.rs b/src/modules/demo_plugin/dispatch.rs new file mode 100644 index 00000000..82e604f9 --- /dev/null +++ b/src/modules/demo_plugin/dispatch.rs @@ -0,0 +1,11 @@ +use crate::plugin::host::HostSession; + +pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool { + 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 new file mode 100644 index 00000000..e8638212 --- /dev/null +++ b/src/modules/demo_plugin/manifest.rs @@ -0,0 +1,14 @@ +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 new file mode 100644 index 00000000..7b374326 --- /dev/null +++ b/src/modules/demo_plugin/mod.rs @@ -0,0 +1,47 @@ +// 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"] +}); + +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()), + })], + }] + } +} + +#[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 new file mode 100644 index 00000000..2d3e074e --- /dev/null +++ b/src/modules/demo_plugin/plugin.rs @@ -0,0 +1,21 @@ +use crate::plugin::host::{BuiltinPlugin, HostSession}; +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 HostSession<'_>, 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 new file mode 100644 index 00000000..16703b27 --- /dev/null +++ b/src/modules/demo_plugin/plugin.toml @@ -0,0 +1,13 @@ +[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 new file mode 100644 index 00000000..e89aa2f2 --- /dev/null +++ b/src/modules/demo_plugin/register.rs @@ -0,0 +1,7 @@ +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 44790f05..3dc374d8 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -139,6 +139,7 @@ pub mod home; pub mod insert; pub mod model; pub mod layout; +pub mod demo_plugin; pub mod manage; pub mod view; diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index a2f420a2..c7b1e9f0 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -39,4 +39,50 @@ pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bo } } 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 addon_ribbon_tabs_merge_after_core() { + 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 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 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/command_line.rs b/src/ui/command_line.rs index 3311cc38..a3426873 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -49,7 +49,7 @@ pub struct HistoryEntry { pub created_at: Instant, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum EntryKind { Command, Output,