feat(app): dispatch HostNotifications to native plugins (API v9)

Close the no-load-callback gap: on_notification was a dead dispatch sink —
a plugin had no way to learn that the active document changed, the selection
changed, or that a document opened, so runtime designations could not be
re-seeded after a load.

Dispatch the existing plain variants at the fingerprint-guarded boundaries
the v4 bridge already uses: DocumentChanged (the active tab's settled
geometry epoch) and SelectionChanged (the selection set) leave through
BuiltinPlugin::on_notification at the post-message boundary — after the
mutation settles and pending history scopes closed, never mid-undo or
mid-pick — and at the automation post-op boundary. Plugin-initiated writes
publish immediately after settling, like the drawing-changed event; the
boundary fingerprint guard deduplicates.

Add the additive HostNotification::DocumentOpened { tab_id } variant
(discriminant 10, the counterpart of DocumentTabClosed): fired exactly once
per whole-document install — file open, template new, system open — keyed on
a new per-tab document generation, so an edit can never repeat it and a
reload fires it once. The API version stays v9 (the bump is unreleased).

Write semantics for handlers, tested and documented: a handler may call
HostApi write methods through the same session (set_reference_only at
minimum); its write bumps the epoch, the next boundary notifies once more,
and because re-marking already-designated geometry is a documented no-op —
no epoch bump, no undo step, no publish — the loop terminates. History
(undo/redo) replay itself emits nothing; the boundary is the only channel.
No Python mapping, like the rest of this increment.
This commit is contained in:
Sebastian committed 2026-10-14 21:15:42 +02:00
1 parent 50db12b169
commit 2cdc1df021
11 files changed
+536 -11

No files matched your search

+76
View File
@@ -48,6 +48,31 @@ pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec<codec::Handle>) {
broadcast(HostNotification::SelectionChangedV4 { tab_id, handles });
}
/// Broadcast the tab-independent `HostNotification::SelectionChanged` to every
/// loaded native plugin (API v9): the plain-handle variant of the selection
/// event, dispatched at the same fingerprint-guarded message boundary as the
/// tab-scoped one above (`OpenCADStudio::notify_plugins_selection_changed`).
pub fn publish_selection_changed(handles: Vec<codec::Handle>) {
broadcast(HostNotification::SelectionChanged { handles });
}
/// Broadcast the tab-independent `HostNotification::DocumentChanged` to every
/// loaded native plugin (API v9): the geometry epoch of the active tab's
/// document after a mutation settled, dispatched at the fingerprint-guarded
/// message boundary (`OpenCADStudio::notify_plugins_document_changed`).
pub fn publish_document_changed(version: u64) {
broadcast(HostNotification::DocumentChanged { version });
}
/// Broadcast `HostNotification::DocumentOpened` to every loaded native plugin
/// (API v9): a whole document became resident in a tab — opened from file,
/// created from a template, or loaded by a system open. The counterpart of
/// [`HostNotification::DocumentTabClosed`], so a plugin can re-seed runtime
/// per-document state (e.g. reference-only designations) from load to close.
pub fn publish_document_opened(tab_id: u64) {
broadcast(HostNotification::DocumentOpened { tab_id });
}
/// Broadcast a change in a tab's active interactive command.
pub fn publish_command_state_changed(tab_id: u64, command: Option<String>) {
broadcast(HostNotification::CommandStateChanged { tab_id, command });
@@ -71,7 +96,58 @@ pub fn notification_handler() -> NotificationHandler {
}
fn broadcast(notification: HostNotification) {
#[cfg(test)]
record_test_dispatch(&notification);
crate::plugin::external::with_manager(|mgr| {
mgr.broadcast_notification(None, notification);
});
}
// ── Test-only dispatch observation ──────────────────────────────────────────
//
// App-level tests assert boundary dispatch behaviour through this per-thread
// log: `cargo test` runs each test on its own thread, so a thread-local log
// records exactly the notifications this test's app instance broadcast,
// without cross-test interference. One recorded entry stands for one fan-out
// to every loaded native plugin (the manager's broadcast), so counts here are
// counts per plugin.
#[cfg(test)]
thread_local! {
static TEST_DISPATCH_LOG: std::cell::RefCell<Vec<String>> =
const { std::cell::RefCell::new(Vec::new()) };
}
#[cfg(test)]
fn record_test_dispatch(notification: &HostNotification) {
let kind = match notification {
HostNotification::DocumentChanged { version } => {
format!("DocumentChanged({version})")
}
HostNotification::SelectionChanged { handles } => {
format!("SelectionChanged({} handles)", handles.len())
}
HostNotification::DocumentOpened { tab_id } => format!("DocumentOpened({tab_id})"),
HostNotification::DocumentTabClosed { tab_id } => format!("DocumentTabClosed({tab_id})"),
HostNotification::DrawingChanged { tab_id, epoch } => {
format!("DrawingChanged({tab_id}, {epoch})")
}
HostNotification::SelectionChangedV4 { tab_id, .. } => {
format!("SelectionChangedV4({tab_id})")
}
HostNotification::DocumentChangedV4 { tab_id, version } => {
format!("DocumentChangedV4({tab_id}, {version})")
}
HostNotification::CommandStateChanged { tab_id, .. } => {
format!("CommandStateChanged({tab_id})")
}
_ => "Other".to_string(),
};
TEST_DISPATCH_LOG.with(|log| log.borrow_mut().push(kind));
}
/// Drain this thread's recorded plugin-notification broadcasts (test-only).
#[cfg(test)]
pub fn take_test_dispatch_log() -> Vec<String> {
TEST_DISPATCH_LOG.with(|log| std::mem::take(&mut *log.borrow_mut()))
}