cad-editor/src/app/plugin_host.rs
Hakan Seven 5ccf5cfe36 feat(plugin): lift host surface behind a HostApi trait (phase 1b)
Plugins targeted the host's concrete HostSession type. Add a HostApi
trait in ocs_plugin_api behind an optional `host` feature (the only thing
that pulls acadrust, so the core crate stays dependency-free). HostSession
implements it and BuiltinPlugin::dispatch now takes `&mut dyn HostApi`, so
an out-of-tree add-on compiles against the contract crate alone. Per-tab
plugin state is reached through object-safe plugin_state* helpers.

Completes the phase-1 host-surface extraction in the #100 epic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:31:05 +03:00

295 lines
No EOL
9.8 KiB
Rust

// HostSession — plugin-facing API implemented inside `app` (private field access).
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;
/// Session adapter: one active document tab, command line, undo.
pub(crate) struct HostSession<'a> {
app: &'a mut OpenCADStudio,
tab: usize,
}
impl<'a> HostSession<'a> {
pub(crate) fn new(app: &'a mut OpenCADStudio, tab: usize) -> Self {
Self { app, tab }
}
pub fn tab_index(&self) -> usize {
self.tab
}
pub fn document(&self) -> &CadDocument {
&self.app.tabs[self.tab].scene.document
}
pub fn document_mut(&mut self) -> &mut CadDocument {
&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)
}
pub fn bump_geometry(&mut self) {
self.app.tabs[self.tab].scene.bump_geometry();
}
// ── XDATA convenience ──────────────────────────────────────────────────
// Plugins persist domain data as XDATA on plain entities so it round-trips
// through DWG/DXF. These wrap the `acadrust::xdata` API keyed by entity
// handle and keep the APPID table in sync.
/// Read the XDATA record for `app_name` attached to entity `handle`, if any.
pub fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord> {
self.document()
.get_entity(handle)?
.common()
.extended_data
.get_record(app_name)
}
/// Attach `record` to entity `handle`, replacing any existing record for the
/// same application. Registers the application in the APPID table when
/// missing so the file stays valid for other CAD apps. Returns `false` when
/// the entity does not exist.
pub fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool {
let app = record.application_name.clone();
self.ensure_app_id(&app);
let Some(entity) = self.document_mut().get_entity_mut(handle) else {
return false;
};
let xd = &mut entity.common_mut().extended_data;
// Drop any existing record for this app, then append the new one.
let kept: Vec<_> = xd
.records()
.iter()
.filter(|r| r.application_name != app)
.cloned()
.collect();
xd.clear();
for r in kept {
xd.add_record(r);
}
xd.add_record(record);
true
}
/// Remove the XDATA record for `app_name` from entity `handle`. Returns
/// `true` when a record was actually removed.
pub fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool {
let Some(entity) = self.document_mut().get_entity_mut(handle) else {
return false;
};
let xd = &mut entity.common_mut().extended_data;
let kept: Vec<_> = xd
.records()
.iter()
.filter(|r| r.application_name != app_name)
.cloned()
.collect();
if kept.len() == xd.records().len() {
return false;
}
xd.clear();
for r in kept {
xd.add_record(r);
}
true
}
/// Register `name` in the APPID table if it is not already present, so XDATA
/// written under it survives a DWG/DXF round-trip.
fn ensure_app_id(&mut self, name: &str) {
let doc = self.document_mut();
if !doc.app_ids.contains(name) {
let _ = doc.app_ids.add(AppId::new(name));
}
}
pub fn push_undo(&mut self, label: &str) {
self.app.push_undo_snapshot(self.tab, label);
}
pub fn set_dirty(&mut self) {
self.app.tabs[self.tab].dirty = true;
}
pub fn push_info(&mut self, msg: &str) {
self.app.command_line.push_info(msg);
}
pub fn push_output(&mut self, msg: &str) {
self.app.command_line.push_output(msg);
}
pub fn push_error(&mut self, msg: &str) {
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
/// 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<dyn Any + Send + Sync>,
) -> &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::*;
use crate::app::OpenCADStudio;
use acadrust::entities::Point;
use acadrust::xdata::XDataValue;
#[test]
fn xdata_record_round_trips_and_registers_appid() {
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
let h = host.add_entity(EntityType::Point(Point::new()));
let mut rec = ExtendedDataRecord::new("DEMO_SURVEY");
rec.add_value(XDataValue::String("PNT-1".to_string()));
rec.add_value(XDataValue::Integer32(42));
assert!(host.write_record(h, rec));
let got = host.read_record(h, "DEMO_SURVEY").expect("record missing");
assert_eq!(got.values.len(), 2);
// APPID registered so the XDATA survives a DWG/DXF round-trip.
assert!(host.document().app_ids.contains("DEMO_SURVEY"));
// A second write replaces rather than duplicates the record.
let mut rec2 = ExtendedDataRecord::new("DEMO_SURVEY");
rec2.add_value(XDataValue::String("PNT-2".to_string()));
assert!(host.write_record(h, rec2));
let got = host.read_record(h, "DEMO_SURVEY").unwrap();
assert_eq!(got.values.len(), 1);
// Removal reports whether anything was dropped.
assert!(host.remove_record(h, "DEMO_SURVEY"));
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::<u32>(&*host, "opencad.demo").is_none());
// Insert via ensure, then mutate.
*host::ensure_plugin_state(host, "opencad.demo", || 7u32) += 1;
assert_eq!(*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(), 8);
*host::plugin_state_mut::<u32>(host, "opencad.demo").unwrap() = 100;
assert_eq!(*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(), 100);
}
}