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

+27
View File
@@ -18,6 +18,17 @@ use std::sync::Arc;
static NEXT_DOCUMENT_TAB_ID: AtomicU64 = AtomicU64::new(1);
/// Monotonic source for `DocumentTab::document_generation` (API v9): every
/// minted value is process-unique, so any freshly installed document — file
/// open, template new, system open — differs from every generation a plugin
/// has been told about, and the opened-notification guard fires exactly once
/// per load.
static NEXT_DOCUMENT_GENERATION: AtomicU64 = AtomicU64::new(1);
fn next_document_generation() -> u64 {
NEXT_DOCUMENT_GENERATION.fetch_add(1, Ordering::Relaxed)
}
// ── Dynamic input ──────────────────────────────────────────────────────────
/// One quantity shown in the dynamic-input overlay near the cursor.
@@ -147,6 +158,13 @@ pub(super) struct DocumentTab {
/// Monotonic committed-edit/undo/redo revision. Background save completion
/// uses it with scene epochs so an older snapshot never clears newer work.
pub(super) edit_revision: u64,
/// Monotonic identity of the document currently resident in this tab: a
/// fresh value is minted whenever a whole document replaces the tab's
/// content (file open, template new, system open), and edits leave it
/// alone. The plugin-opened notification guard
/// (`OpenCADStudio::notify_plugins_document_opened`, API v9) keys on it
/// so `DocumentOpened` dispatches exactly once per load.
pub(super) document_generation: u64,
pub(super) tab_title: String,
pub(super) properties: PropertiesPanel,
pub(super) layers: LayerPanel,
@@ -623,6 +641,7 @@ impl DocumentTab {
dirty: false,
recovery_save_as_required: false,
edit_revision: 0,
document_generation: next_document_generation(),
prev_selection: Vec::new(),
tab_title: format!("Drawing{}", n),
properties: PropertiesPanel::empty(),
@@ -692,6 +711,14 @@ impl DocumentTab {
t
}
/// A whole document just replaced this tab's content (file open, template
/// new, system open): mint a fresh generation so the message-boundary
/// dispatch announces `DocumentOpened` exactly once for this load (API
/// v9). Call after the document and its derived caches have settled.
pub(super) fn note_document_loaded(&mut self) {
self.document_generation = next_document_generation();
}
pub(super) fn tab_display_name(&self) -> String {
match &self.current_path {
Some(p) => p