diff --git a/Cargo.lock b/Cargo.lock index c308044c..83795bf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3626,6 +3626,9 @@ dependencies = [ [[package]] name = "ocs_plugin_api" version = "0.1.0" +dependencies = [ + "acadrust", +] [[package]] name = "once_cell" diff --git a/Cargo.toml b/Cargo.toml index 600d2f17..6f992d8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ solid3d = ["dep:truck-meshalgo", "dep:truck-shapeops", "dep:lzma-sys"] [dependencies] # Stable, dependency-free add-on contract (manifest + ribbon/CadModule types). # Plugin authors target this crate's semver, not OpenCADStudio internals. -ocs_plugin_api = { path = "crates/ocs_plugin_api" } +ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } # vtkio (pulled in transitively via truck-meshalgo) depends on xz2 → lzma-sys, # which by default links the system liblzma dynamically. On macOS that bakes a # Homebrew dylib path (/opt/homebrew/.../liblzma.5.dylib) into the binary, so diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 4e2ea76d..535912cd 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -6,3 +6,12 @@ description = "Stable, dependency-free contract for Open CAD Studio add-on packa license = "GPL-3.0-only" [dependencies] +# Pulled in only by the `host` feature, which adds the `acadrust`-typed +# `HostApi` runtime surface. The default crate stays dependency-free so engine +# crates and external tooling can depend on the manifest/ribbon contract cheaply. +acadrust = { version = "0.3.4", optional = true } + +[features] +# Enables the runtime host surface (`HostApi` trait). The OpenCADStudio binary +# turns this on; pure-data consumers leave it off. +host = ["dep:acadrust"] diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs new file mode 100644 index 00000000..f7a3f502 --- /dev/null +++ b/crates/ocs_plugin_api/src/host.rs @@ -0,0 +1,92 @@ +//! Runtime host surface (`host` feature). +//! +//! [`HostApi`] is the `acadrust`-typed adapter a plugin uses at *dispatch* time +//! — document access, entity creation, XDATA, undo, and the command line. It is +//! the stable counterpart to the dependency-free manifest/ribbon contract: a +//! plugin's `dispatch` receives `&mut dyn HostApi` rather than the host's +//! concrete session type, so an out-of-tree add-on compiles against this crate +//! alone. +//! +//! Per-tab plugin state is keyed by `manifest.id`. The trait exposes it in an +//! object-safe `Any` form; use the [`plugin_state`], [`plugin_state_mut`] and +//! [`ensure_plugin_state`] helpers for the ergonomic typed access. + +use std::any::Any; + +use acadrust::xdata::ExtendedDataRecord; +use acadrust::{CadDocument, EntityType, Handle}; + +/// The plugin-facing runtime surface for one active document tab. +pub trait HostApi { + /// Index of the tab this session targets. + fn tab_index(&self) -> usize; + + // ── Document ──────────────────────────────────────────────────────────── + fn document(&self) -> &CadDocument; + fn document_mut(&mut self) -> &mut CadDocument; + /// Add an entity to the active document, returning its handle. + fn add_entity(&mut self, entity: EntityType) -> Handle; + /// Mark the scene geometry dirty so it is re-tessellated next frame. + fn bump_geometry(&mut self); + + // ── XDATA ─────────────────────────────────────────────────────────────── + /// Read the XDATA record for `app_name` on entity `handle`, if any. + fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord>; + /// Attach `record` to entity `handle`, replacing any existing record for the + /// same application and registering the APPID. Returns `false` if the entity + /// does not exist. + fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool; + /// Remove the XDATA record for `app_name` from entity `handle`. Returns + /// `true` if a record was removed. + fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool; + + // ── Undo / dirty ──────────────────────────────────────────────────────── + fn push_undo(&mut self, label: &str); + fn set_dirty(&mut self); + + // ── Command line ──────────────────────────────────────────────────────── + fn push_info(&mut self, msg: &str); + fn push_output(&mut self, msg: &str); + fn push_error(&mut self, msg: &str); + + // ── Per-tab plugin state (object-safe; use the typed helpers below) ────── + fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)>; + fn plugin_state_any_mut(&mut self, plugin_id: &str) + -> Option<&mut (dyn Any + Send + Sync)>; + /// Get the state for `plugin_id`, inserting `init()`'s result if absent. + fn ensure_plugin_state_any( + &mut self, + plugin_id: &'static str, + init: &mut dyn FnMut() -> Box, + ) -> &mut (dyn Any + Send + Sync); +} + +/// Typed read of per-tab plugin state stored under `plugin_id`. +pub fn plugin_state<'a, T: Any + Send + Sync>( + host: &'a dyn HostApi, + plugin_id: &str, +) -> Option<&'a T> { + host.plugin_state_any(plugin_id)?.downcast_ref::() +} + +/// Typed mutable access to per-tab plugin state stored under `plugin_id`. +pub fn plugin_state_mut<'a, T: Any + Send + Sync>( + host: &'a mut dyn HostApi, + plugin_id: &str, +) -> Option<&'a mut T> { + host.plugin_state_any_mut(plugin_id)?.downcast_mut::() +} + +/// Typed get-or-insert of per-tab plugin state stored under `plugin_id`. +pub fn ensure_plugin_state<'a, T: Any + Send + Sync>( + host: &'a mut dyn HostApi, + plugin_id: &'static str, + init: impl FnOnce() -> T, +) -> &'a mut T { + let mut init = Some(init); + let any = host.ensure_plugin_state_any(plugin_id, &mut || { + Box::new((init.take().expect("init called once"))()) + }); + any.downcast_mut::() + .expect("plugin state type mismatch for plugin_id") +} diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index 7e217379..cff3749d 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -19,6 +19,10 @@ pub mod manifest; pub mod ribbon; +/// Runtime host surface — only built with the `host` feature (pulls `acadrust`). +#[cfg(feature = "host")] +pub mod host; + pub use manifest::{ApiVersion, PluginManifest, API_VERSION}; pub use ribbon::{ CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef, diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index cf976c08..0fc964aa 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -150,7 +150,7 @@ pub struct PluginManifest { pub trait BuiltinPlugin: Send + Sync { fn manifest(&self) -> &'static PluginManifest; fn ribbon(&self) -> Box; - fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool; + fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool; } ``` @@ -183,14 +183,20 @@ Plugins use `HostSession`, not `OpenCADStudio`: | Command line | `push_info`, `push_output`, `push_error`, `set_active_command` | | Undo / dirty | `push_undo`, `set_dirty` | -**Status:** The dependency-free half of the contract — `PluginManifest` / -`ApiVersion` (manifest) and `CadModule` + the ribbon types (`ToolDef`, -`RibbonGroup`, …) — now lives in the standalone, semver-versioned -[`crates/ocs_plugin_api`](../crates/ocs_plugin_api) crate. The host re-exports it -(`crate::plugin::manifest`, `crate::modules`) so in-tree paths are unchanged. -The `acadrust`-typed runtime surface in the table above (`document_mut`, -`add_entity`, `set_active_command`, …) stays in the host binary for now; lifting -it behind a `HostApi` trait in the same crate is the remaining phase-1b step. +**Status:** The whole contract now lives in the standalone, semver-versioned +[`crates/ocs_plugin_api`](../crates/ocs_plugin_api) crate: +- **Dependency-free core** — `PluginManifest` / `ApiVersion` (manifest) and + `CadModule` + ribbon types (`ToolDef`, `RibbonGroup`, …). Engine crates and + external tooling depend on this cheaply. +- **`host` feature** — the `acadrust`-typed `HostApi` trait (the runtime surface + in the table above). `HostSession` in the binary implements it; a plugin's + `dispatch` receives `&mut dyn HostApi`, so an out-of-tree add-on compiles + against this crate alone. Per-tab plugin state is reached through the + object-safe `plugin_state*` helpers. `set_active_command` (interactive + acquisition) stays host-side for now — see Command routing. + +The host re-exports both (`crate::plugin::manifest`, `crate::modules`, +`crate::plugin::host::HostApi`) so in-tree paths are unchanged. ### Command routing @@ -255,7 +261,7 @@ This mirrors QGIS: the application ships core menus; plugins add tabs/tools with - [x] Per-tab `plugin_state` - [x] Storm Sewer off `commands.rs` monolith - [x] Single registration (`plugin.toml` + `BuiltinPlugin::ribbon`) -- [~] Extract `ocs_plugin_api` crate — manifest + ribbon/`CadModule` done; `acadrust`-typed host surface pending +- [x] Extract `ocs_plugin_api` crate — dependency-free manifest + ribbon/`CadModule`, plus the `acadrust`-typed `HostApi` trait behind the optional `host` feature; `dispatch` takes `&mut dyn HostApi` - [x] Plugin manager UI (list installed, versions) — `PLUGINS` / `PLUGINMANAGER` command, or the Start-page "Plugins" button - [x] Enable/disable plugins from the manager — a disabled plugin drops its ribbon tab and command dispatch; persisted in `settings.txt` (`disabled_plugins=`) - [x] `ModuleEvent::PluginFileDialog` — a plugin tool requests a native file picker; the host opens it and dispatches `" "` back to the plugin with original case preserved (bypasses the command-line upper-casing) diff --git a/docs/plugin-template/dispatch.rs b/docs/plugin-template/dispatch.rs index 174f6b94..6c70a91c 100644 --- a/docs/plugin-template/dispatch.rs +++ b/docs/plugin-template/dispatch.rs @@ -1,8 +1,8 @@ -use crate::plugin::host::HostSession; +use crate::plugin::host::HostApi; use super::manifest::PLUGIN_ID; -pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool { +pub fn handle(host: &mut dyn HostApi, cmd: &str) -> bool { let _ = (host, PLUGIN_ID); match cmd { "MP_HELLO" => { diff --git a/docs/plugin-template/plugin.rs b/docs/plugin-template/plugin.rs index 6230b57d..92ca34e5 100644 --- a/docs/plugin-template/plugin.rs +++ b/docs/plugin-template/plugin.rs @@ -1,4 +1,4 @@ -use crate::plugin::host::{BuiltinPlugin, HostSession}; +use crate::plugin::host::{BuiltinPlugin, HostApi}; use crate::plugin::manifest::PluginManifest; use super::dispatch; @@ -15,7 +15,7 @@ impl BuiltinPlugin for MyPlugin { Box::new(super::MyPluginModule) } - fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool { + fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { dispatch::handle(host, cmd) } } \ No newline at end of file diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 514ad21e..d96d8726 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -5,6 +5,7 @@ use std::any::{Any, TypeId}; use acadrust::tables::AppId; use acadrust::xdata::ExtendedDataRecord; use acadrust::{CadDocument, EntityType, Handle}; +use ocs_plugin_api::host::HostApi; use super::OpenCADStudio; use crate::command::CadCommand; @@ -167,6 +168,79 @@ impl<'a> HostSession<'a> { } } +/// The stable contract a plugin's `dispatch` sees. Each method forwards to the +/// inherent `HostSession` method of the same name (inherent methods take +/// resolution priority, so this is plain delegation, not recursion). The +/// per-tab plugin-state accessors expose the raw `Any` box; the typed +/// `ocs_plugin_api::host::plugin_state*` helpers wrap them. +impl HostApi for HostSession<'_> { + fn tab_index(&self) -> usize { + self.tab_index() + } + fn document(&self) -> &CadDocument { + self.document() + } + fn document_mut(&mut self) -> &mut CadDocument { + self.document_mut() + } + fn add_entity(&mut self, entity: EntityType) -> Handle { + self.add_entity(entity) + } + fn bump_geometry(&mut self) { + self.bump_geometry() + } + fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord> { + self.read_record(handle, app_name) + } + fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool { + self.write_record(handle, record) + } + fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool { + self.remove_record(handle, app_name) + } + fn push_undo(&mut self, label: &str) { + self.push_undo(label) + } + fn set_dirty(&mut self) { + self.set_dirty() + } + fn push_info(&mut self, msg: &str) { + self.push_info(msg) + } + fn push_output(&mut self, msg: &str) { + self.push_output(msg) + } + fn push_error(&mut self, msg: &str) { + self.push_error(msg) + } + fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> { + self.app.tabs[self.tab] + .plugin_state + .get(plugin_id) + .map(|b| b.as_ref()) + } + fn plugin_state_any_mut( + &mut self, + plugin_id: &str, + ) -> Option<&mut (dyn Any + Send + Sync)> { + self.app.tabs[self.tab] + .plugin_state + .get_mut(plugin_id) + .map(|b| b.as_mut()) + } + fn ensure_plugin_state_any( + &mut self, + plugin_id: &'static str, + init: &mut dyn FnMut() -> Box, + ) -> &mut (dyn Any + Send + Sync) { + self.app.tabs[self.tab] + .plugin_state + .entry(plugin_id) + .or_insert_with(|| init()) + .as_mut() + } +} + #[cfg(test)] mod tests { use super::*; @@ -202,4 +276,20 @@ mod tests { assert!(host.read_record(h, "DEMO_SURVEY").is_none()); assert!(!host.remove_record(h, "DEMO_SURVEY")); } + + #[test] + fn plugin_state_round_trips_through_hostapi_trait() { + use ocs_plugin_api::host::{self, HostApi}; + let mut app = OpenCADStudio::new_for_test(); + let mut session = HostSession::new(&mut app, 0); + let host: &mut dyn HostApi = &mut session; + + // Absent before first use. + assert!(host::plugin_state::(&*host, "opencad.demo").is_none()); + // Insert via ensure, then mutate. + *host::ensure_plugin_state(host, "opencad.demo", || 7u32) += 1; + assert_eq!(*host::plugin_state::(&*host, "opencad.demo").unwrap(), 8); + *host::plugin_state_mut::(host, "opencad.demo").unwrap() = 100; + assert_eq!(*host::plugin_state::(&*host, "opencad.demo").unwrap(), 100); + } } \ No newline at end of file diff --git a/src/modules/demo_plugin/dispatch.rs b/src/modules/demo_plugin/dispatch.rs index d4344e0d..ddb2aab1 100644 --- a/src/modules/demo_plugin/dispatch.rs +++ b/src/modules/demo_plugin/dispatch.rs @@ -1,6 +1,6 @@ -use crate::plugin::host::HostSession; +use crate::plugin::host::HostApi; -pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool { +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 ") { diff --git a/src/modules/demo_plugin/plugin.rs b/src/modules/demo_plugin/plugin.rs index 2d3e074e..1b9ec538 100644 --- a/src/modules/demo_plugin/plugin.rs +++ b/src/modules/demo_plugin/plugin.rs @@ -1,4 +1,4 @@ -use crate::plugin::host::{BuiltinPlugin, HostSession}; +use crate::plugin::host::{BuiltinPlugin, HostApi}; use crate::plugin::manifest::PluginManifest; use super::dispatch; @@ -15,7 +15,7 @@ impl BuiltinPlugin for DemoPlugin { Box::new(super::DemoPluginModule) } - fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool { + fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { dispatch::handle(host, cmd) } } \ No newline at end of file diff --git a/src/plugin/host.rs b/src/plugin/host.rs index da1c53ad..f4ca3a50 100644 --- a/src/plugin/host.rs +++ b/src/plugin/host.rs @@ -1,6 +1,9 @@ -// Plugin traits — HostSession lives in `app::plugin_host` (same-crate field access). +// 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 runtime surface a plugin's `dispatch` receives. +pub use ocs_plugin_api::host::HostApi; use crate::modules::CadModule; @@ -9,9 +12,11 @@ use super::manifest::PluginManifest; /// Add-on package entry point (phase 1: in-tree, in-process). /// /// One `PluginRegistration` per package — ribbon tab, manifest, and command -/// dispatch are owned here. See `docs/plugin-architecture.md`. +/// dispatch are owned here. `dispatch` receives `&mut dyn HostApi` (the stable +/// `ocs_plugin_api` contract), not the host's concrete session type. See +/// `docs/plugin-architecture.md`. pub trait BuiltinPlugin: Send + Sync { fn manifest(&self) -> &'static PluginManifest; fn ribbon(&self) -> Box; - fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool; + fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool; } \ No newline at end of file