forked from 1b-agent/opencadstudio-upstream
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:
1 parent
50db12b169
commit
2cdc1df021
11 files changed
+536
-11
No files matched your search
@@ -396,6 +396,15 @@ pub enum HostNotification {
|
||||
tab_id: u64,
|
||||
epoch: u64,
|
||||
},
|
||||
/// V9 a document became resident in a tab: a whole document replaced the
|
||||
/// tab's previous content — opened from file, created from a template, or
|
||||
/// loaded by an automation/system open. The counterpart of
|
||||
/// [`HostNotification::DocumentTabClosed`]: with both, a plugin can keep
|
||||
/// per-document state (e.g. runtime designations) in sync from load to
|
||||
/// close. Discriminant 10.
|
||||
DocumentOpened {
|
||||
tab_id: u64,
|
||||
},
|
||||
/// Fallback for notification variants added in future minor revisions.
|
||||
/// Carries the raw bincode payload so an older peer can ignore it without
|
||||
/// failing deserialization.
|
||||
@@ -447,6 +456,10 @@ impl Serialize for HostNotification {
|
||||
bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?;
|
||||
bincode::serialize_into(&mut bytes, epoch).map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
HostNotification::DocumentOpened { tab_id } => {
|
||||
bytes.push(10);
|
||||
bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
HostNotification::Unknown(raw) => bytes.extend_from_slice(raw),
|
||||
}
|
||||
bytes.serialize(serializer)
|
||||
@@ -490,6 +503,9 @@ impl<'de> Deserialize<'de> for HostNotification {
|
||||
9 => bincode::deserialize(rest)
|
||||
.map(|(tab_id, epoch)| HostNotification::DrawingChanged { tab_id, epoch })
|
||||
.map_err(serde::de::Error::custom),
|
||||
10 => bincode::deserialize(rest)
|
||||
.map(|tab_id| HostNotification::DocumentOpened { tab_id })
|
||||
.map_err(serde::de::Error::custom),
|
||||
_ => Ok(HostNotification::Unknown(bytes)),
|
||||
}
|
||||
}
|
||||
@@ -1195,9 +1211,10 @@ pub trait HostApi {
|
||||
/// handle: it is not written to the saved DWG/DXF (the host stores no
|
||||
/// state of its own in the file), it lives and dies with the drawing tab,
|
||||
/// and a reloaded drawing starts undesignated — the owning plugin
|
||||
/// re-marks its entities when the document opens, which makes the
|
||||
/// in-memory designation fully equivalent to a persisted one for its use
|
||||
/// case.
|
||||
/// re-marks its entities when
|
||||
/// [`on_notification`](BuiltinPlugin::on_notification) delivers
|
||||
/// [`HostNotification::DocumentOpened`], making the in-memory designation
|
||||
/// fully equivalent to a persisted one for its use case.
|
||||
///
|
||||
/// Fails when the entity does not exist or is on a locked layer (the same
|
||||
/// policy as the XDATA record methods). A call that would not change the
|
||||
|
||||
@@ -401,7 +401,7 @@ and `box_selections_skip_reference_only_wires`; the record surface test
|
||||
`reference_only_request_and_response_roundtrip`. Like the XRecord API, a Python mapping is future
|
||||
work.
|
||||
|
||||
**Reference-only rework: scene-scoped storage (2026-10-14, API v9).**
|
||||
**Reference-only rework: scene-scoped storage and notification dispatch (2026-10-14, API v9).**
|
||||
Supersedes the storage decision above after the upstream review: the designation is no longer
|
||||
written to the file at all. The XDATA marker under the host-owned `OCS` APPID (and the APPID
|
||||
registration, the verbatim-EED rewrite handling and the storage unit tests built on it) is removed;
|
||||
@@ -418,8 +418,27 @@ idempotent re-mark records nothing, erasing a designated entity drops the set en
|
||||
step, and undo/redo restores entity and designation together. The round-trip test is inverted:
|
||||
`reference_only_designation_is_not_written_to_dwg_or_dxf` now asserts the marker appears in
|
||||
neither format's bytes, that no `OCS` APPID or marker record survives a reload, and that the
|
||||
designation stays live in memory. Evidence: the set-storage tests
|
||||
`marking_and_unmarking_report_and_track_membership`, `removing_a_handle_forgets_it`,
|
||||
designation stays live in memory.
|
||||
|
||||
The same rework closes the missing load callback with real notification dispatch: the host now
|
||||
delivers the existing `HostNotification`s to every loaded native plugin through
|
||||
`BuiltinPlugin::on_notification` — `DocumentChanged` (the active tab's settled geometry epoch)
|
||||
and `SelectionChanged` (the plain selection set) at the fingerprint-guarded message boundaries
|
||||
the v4 bridge already uses (`notify_plugins_selection_changed` / `notify_plugins_document_changed`,
|
||||
plus the automation post-op boundary), and a new additive `DocumentOpened { tab_id }` variant
|
||||
(discriminant 10, the counterpart of the existing `DocumentTabClosed`) that fires once per whole
|
||||
document install — file open, template new, system open — keyed on a new per-tab document
|
||||
generation. Boundary rule: dispatch happens after the mutation settles and pending history scopes
|
||||
closed, never mid-undo/mid-pick, and history (undo/redo) replay itself emits nothing — the
|
||||
boundary is the only channel; plugin-initiated writes publish immediately after settling, like
|
||||
the drawing-changed event, with the boundary fingerprint deduplicating. Handlers may call HostApi
|
||||
write methods through the same session (`set_reference_only` at minimum), and the loop closes
|
||||
because an idempotent re-mark bumps nothing: no epoch, no undo step, no publish. With
|
||||
`DocumentOpened`, a plugin re-marks on every load and the in-memory designation is fully
|
||||
equivalent to a persisted one for its use case; the remaining gap (a plugin process restarted
|
||||
while its drawing is open) stays open and documented — the plugin's own session persistence
|
||||
covers it on reopen. No Python mapping, like the rest of this increment. Evidence: the set-storage
|
||||
tests `marking_and_unmarking_report_and_track_membership`, `removing_a_handle_forgets_it`,
|
||||
`retain_present_drops_entries_of_absent_entities`, `scene_membership_is_guarded_by_entity_presence`
|
||||
and `tessellated_wires_stamp_from_the_designation_set`; the host tests
|
||||
`reference_only_refusals_record_no_undo_step`,
|
||||
@@ -428,5 +447,12 @@ and `tessellated_wires_stamp_from_the_designation_set`; the host tests
|
||||
`erasing_a_designated_entity_undoes_designation_with_it`,
|
||||
`user_paths_refuse_and_plugin_paths_write_reference_only_entities`,
|
||||
`select_all_visible_skips_reference_only_entities`,
|
||||
`entities_records_expose_the_reference_only_designation`; the unchanged pick tests; and the IPC
|
||||
round-trip `reference_only_request_and_response_roundtrip` (unchanged).
|
||||
`entities_records_expose_the_reference_only_designation`; the unchanged pick tests; the dispatch
|
||||
tests `an_edit_dispatches_document_changed_once_per_epoch`,
|
||||
`a_selection_change_dispatches_selection_changed`,
|
||||
`a_document_open_dispatches_document_opened_once`,
|
||||
`a_file_open_dispatches_document_opened_through_the_message_flow`,
|
||||
`idempotent_designation_remarks_cannot_loop_the_dispatch`,
|
||||
`history_replay_emits_no_plugin_notifications_itself` and
|
||||
`dispatching_without_loaded_plugins_leaves_the_app_state_untouched`; and the IPC round-trip
|
||||
`reference_only_request_and_response_roundtrip` (unchanged).
|
||||
@@ -141,6 +141,31 @@ designation. Out of scope today: entities nested inside block instances
|
||||
mapping. The MCP `entities` read exposes the flag as a `reference_only`
|
||||
boolean on every entity record.
|
||||
|
||||
**Re-marking on load: `HostNotification` dispatch (API v9).** The host
|
||||
dispatches the existing `HostNotification`s to every loaded native plugin
|
||||
through `BuiltinPlugin::on_notification`: `DocumentChanged` (carrying the
|
||||
active tab's settled geometry epoch), `SelectionChanged` (the plain selection
|
||||
set), and a new `DocumentOpened` — the counterpart of the existing
|
||||
`DocumentTabClosed` — which fires once whenever a whole document becomes
|
||||
resident in a tab (file open, template new, system open). With
|
||||
`DocumentOpened`, the owning plugin re-marks its entities when a drawing
|
||||
loads, which makes the in-memory designation fully equivalent to a persisted
|
||||
one for its use case. Dispatch happens at the message boundary — after the
|
||||
mutation settles and pending history scopes have closed — never mid-undo or
|
||||
mid-pick; history (undo/redo) replay itself emits nothing, the boundary is
|
||||
the only channel. Plugin-initiated writes publish immediately after they
|
||||
settle (the same contract the drawing-changed event follows), and the
|
||||
boundary's fingerprint guard deduplicates. Handlers may call HostApi write
|
||||
methods through the same session (`set_reference_only` at minimum;
|
||||
`update_entity` for reference-only refresh goes through the plugin write
|
||||
paths): a handler's write bumps the epoch, the next boundary notifies once
|
||||
more, and because a re-mark of already-designated geometry is a documented
|
||||
no-op — no epoch bump, no undo step, no publish — the loop terminates. The
|
||||
remaining gap stays open and documented: a plugin process restarted while
|
||||
its drawing is open has no snapshot of the designations (the plugin's own
|
||||
session persistence closes it; reopen from disk re-marks via
|
||||
`DocumentOpened`). Not mapped to Python.
|
||||
|
||||
### Usage from a plugin
|
||||
|
||||
Build the derived geometry and designate it inside one `push_undo` scope, so
|
||||
@@ -180,8 +205,9 @@ Notes for plugin authors:
|
||||
- The designation travels with the entity: a clone → edit → replace refresh
|
||||
keeps it, so a plugin marks its display data once and refreshes freely.
|
||||
- The designation is scene state, not file state: it does not survive
|
||||
DWG/DXF save and reload — a plugin re-marks its display data when the
|
||||
document opens, and idempotent re-marks keep that loop finite.
|
||||
DWG/DXF save and reload. Subscribe to `on_notification` and re-mark on
|
||||
`DocumentOpened`; the idempotent re-mark guarantees the
|
||||
`DocumentChanged`-driven loop terminates.
|
||||
- Rendering and object snap are unaffected; grips, picking and the built-in
|
||||
write paths are the only observers.
|
||||
- Plugins that read the document through the IPC client (the
|
||||
|
||||
@@ -690,6 +690,10 @@ impl OpenCADStudio {
|
||||
self.notify_plugins_selection_changed();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.notify_plugins_document_changed();
|
||||
// The `new` / `open` / template ops replace a tab's whole document;
|
||||
// announce it on the same post-op boundary (API v9).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.notify_plugins_document_opened();
|
||||
res
|
||||
}
|
||||
|
||||
@@ -719,6 +723,9 @@ impl OpenCADStudio {
|
||||
// The headless session starts on the welcome (Start) tab, which
|
||||
// blocks drawing commands; turn it into a real drawing.
|
||||
self.tabs[i].is_start = false;
|
||||
// A fresh document became resident (API v9): mint a new
|
||||
// generation so the post-op boundary announces `DocumentOpened`.
|
||||
self.tabs[i].note_document_loaded();
|
||||
self.entity_summary()
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -751,6 +758,10 @@ impl OpenCADStudio {
|
||||
self.tabs[i].current_path = Some(path_buf);
|
||||
self.tabs[i].is_start = false;
|
||||
self.tabs[i].scene.rebuild_derived_caches();
|
||||
// The opened document settled (API v9): mint a new
|
||||
// generation so the post-op boundary announces
|
||||
// `DocumentOpened` once for this load.
|
||||
self.tabs[i].note_document_loaded();
|
||||
let mut summary = self.entity_summary();
|
||||
if dropped > 0 {
|
||||
if let Some(obj) = summary.as_object_mut() {
|
||||
@@ -1718,6 +1729,290 @@ mod tests {
|
||||
app.notify_plugins_document_changed();
|
||||
assert_eq!(app.last_plugin_document, Some(published));
|
||||
}
|
||||
|
||||
// ── Native-plugin notification dispatch (API v9) ─────────────────────
|
||||
//
|
||||
// The app-level tests observe the dispatch seam in
|
||||
// `plugin::v4_support`: one recorded entry stands for one broadcast to
|
||||
// every loaded native plugin, so counts here are counts per plugin. The
|
||||
// write-semantics rule under test: dispatch happens at the message
|
||||
// boundary — after the mutation settled, never mid-undo/mid-pick — so
|
||||
// handlers may write through the plugin host API, and the fingerprint
|
||||
// guards plus idempotent re-marks keep that loop finite.
|
||||
|
||||
fn plugin_dispatch_log() -> Vec<String> {
|
||||
crate::plugin::v4_support::take_test_dispatch_log()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn an_edit_dispatches_document_changed_once_per_epoch() {
|
||||
use codec::entities::Point;
|
||||
use codec::EntityType;
|
||||
|
||||
let mut app = super::OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let _ = plugin_dispatch_log();
|
||||
let tab = app.active_tab;
|
||||
|
||||
let mut host = super::super::plugin_host::HostSession::new(&mut app, tab);
|
||||
host.add_entity(EntityType::Point(Point::new()));
|
||||
drop(host);
|
||||
app.notify_plugins_document_changed();
|
||||
|
||||
let epoch = app.tabs[tab].scene.geometry_epoch;
|
||||
let log = plugin_dispatch_log();
|
||||
let changed: Vec<_> = log
|
||||
.iter()
|
||||
.filter(|entry| *entry == &format!("DocumentChanged({epoch})"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
changed.len(),
|
||||
1,
|
||||
"one settled edit dispatches exactly one DocumentChanged per plugin: {log:?}"
|
||||
);
|
||||
|
||||
// The same epoch at the next boundary dispatches nothing further.
|
||||
app.notify_plugins_document_changed();
|
||||
let log = plugin_dispatch_log();
|
||||
assert!(
|
||||
log.iter()
|
||||
.all(|entry| *entry != format!("DocumentChanged({epoch})")),
|
||||
"the fingerprint guard suppresses duplicate dispatches: {log:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_selection_change_dispatches_selection_changed() {
|
||||
let mut app = super::OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#);
|
||||
let _ = plugin_dispatch_log();
|
||||
let i = app.active_tab;
|
||||
|
||||
let handle = app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.next()
|
||||
.expect("the drawn line")
|
||||
.common()
|
||||
.handle;
|
||||
let selection: rustc_hash::FxHashSet<codec::Handle> = std::iter::once(handle).collect();
|
||||
app.tabs[i].scene.replace_selection(selection);
|
||||
app.notify_plugins_selection_changed();
|
||||
|
||||
let log = plugin_dispatch_log();
|
||||
assert!(
|
||||
log.contains(&"SelectionChanged(1 handles)".to_string()),
|
||||
"a real selection change dispatches SelectionChanged: {log:?}"
|
||||
);
|
||||
|
||||
// Unchanged selection at the next boundary: nothing further.
|
||||
app.notify_plugins_selection_changed();
|
||||
let log = plugin_dispatch_log();
|
||||
assert_eq!(log.len(), 0, "no duplicate dispatch: {log:?}");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_document_open_dispatches_document_opened_once() {
|
||||
let mut app = super::OpenCADStudio::new_for_test();
|
||||
let _ = plugin_dispatch_log();
|
||||
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let tab_id = app.tabs[app.active_tab].id;
|
||||
let log = plugin_dispatch_log();
|
||||
let opened: Vec<_> = log
|
||||
.iter()
|
||||
.filter(|entry| **entry == format!("DocumentOpened({tab_id})"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
opened.len(),
|
||||
1,
|
||||
"a new document announces exactly one open: {log:?}"
|
||||
);
|
||||
|
||||
// A follow-up op does not repeat it: generation-keyed dispatch.
|
||||
app.automation_op(r#"{"op":"query","type":"Line"}"#);
|
||||
let log = plugin_dispatch_log();
|
||||
assert!(
|
||||
log.iter()
|
||||
.all(|entry| *entry != format!("DocumentOpened({tab_id})")),
|
||||
"no duplicate open dispatch: {log:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_file_open_dispatches_document_opened_through_the_message_flow() {
|
||||
let mut app = super::OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("ocs_opened_dispatch_{}.dxf", std::process::id(),));
|
||||
crate::io::save(&app.tabs[app.active_tab].scene.document, &path)
|
||||
.expect("save the open fixture");
|
||||
|
||||
app.opening = Some(crate::app::OpenProgress {
|
||||
id: 7,
|
||||
name: "ocs_opened_dispatch.dxf".to_string(),
|
||||
source_path: Some(path.clone()),
|
||||
size_bytes: 0,
|
||||
state: std::sync::Arc::new(crate::io::OpenProgressState::new(
|
||||
crate::app::OPEN_PHASE_CACHING,
|
||||
)),
|
||||
started: std::time::Instant::now(),
|
||||
recovery_error: None,
|
||||
recovery_read_stats: None,
|
||||
fingerprint: None,
|
||||
});
|
||||
let bytes = std::fs::read(&path).expect("read the fixture back");
|
||||
let (document, _dropped) =
|
||||
crate::io::load_bytes_finalized(&path, bytes).expect("reparse the fixture");
|
||||
let caches = crate::scene::build_derived_caches_with_progress(&document, &|_| {}, None);
|
||||
let _ = plugin_dispatch_log();
|
||||
let _ = app.update(crate::app::Message::FileOpened(
|
||||
7,
|
||||
Ok((
|
||||
"ocs_opened_dispatch.dxf".to_string(),
|
||||
path.clone(),
|
||||
document,
|
||||
caches,
|
||||
)),
|
||||
));
|
||||
|
||||
let tab_id = app.tabs[app.active_tab].id;
|
||||
let log = plugin_dispatch_log();
|
||||
let opened: Vec<_> = log
|
||||
.iter()
|
||||
.filter(|entry| **entry == format!("DocumentOpened({tab_id})"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
opened.len(),
|
||||
1,
|
||||
"the file-open message announces exactly one open: {log:?}"
|
||||
);
|
||||
// The next message boundary does not repeat it.
|
||||
app.notify_plugins_document_opened();
|
||||
let log = plugin_dispatch_log();
|
||||
assert_eq!(log.len(), 0, "generation-keyed dispatch dedupes: {log:?}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn idempotent_designation_remarks_cannot_loop_the_dispatch() {
|
||||
use codec::entities::Point;
|
||||
use codec::EntityType;
|
||||
|
||||
// Models the consumer plugin's re-mark handler: on DocumentChanged it
|
||||
// re-marks its entities through the plugin host API. The write goes
|
||||
// through, but because a re-mark of already-designated geometry is a
|
||||
// documented no-op — no epoch bump, no undo step, no publish — the
|
||||
// notification loop terminates instead of feeding itself.
|
||||
let mut app = super::OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let _ = plugin_dispatch_log();
|
||||
let tab = app.active_tab;
|
||||
|
||||
let mut host = super::super::plugin_host::HostSession::new(&mut app, tab);
|
||||
let handle = host.add_entity(EntityType::Point(Point::new()));
|
||||
host.set_reference_only(handle, true).expect("mark");
|
||||
// The handler's "write": an idempotent re-mark after the notification.
|
||||
host.set_reference_only(handle, true)
|
||||
.expect("idempotent re-mark");
|
||||
drop(host);
|
||||
assert!(app.tabs[tab].scene.is_reference_only(handle));
|
||||
|
||||
app.notify_plugins_document_changed();
|
||||
let epoch = app.tabs[tab].scene.geometry_epoch;
|
||||
let log = plugin_dispatch_log();
|
||||
let changed: Vec<_> = log
|
||||
.iter()
|
||||
.filter(|entry| **entry == format!("DocumentChanged({epoch})"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
changed.len(),
|
||||
1,
|
||||
"the designation write dispatched once, not one per re-mark: {log:?}"
|
||||
);
|
||||
|
||||
// A further boundary run finds the same epoch: the loop has ended.
|
||||
app.notify_plugins_document_changed();
|
||||
let log = plugin_dispatch_log();
|
||||
assert_eq!(log.len(), 0, "idempotent re-marks emit nothing: {log:?}");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn history_replay_emits_no_plugin_notifications_itself() {
|
||||
use codec::entities::Point;
|
||||
use codec::EntityType;
|
||||
|
||||
// Undo/redo replay never publishes: the notification boundary is the
|
||||
// only channel, so replaying history steps in isolation is silent.
|
||||
let mut app = super::OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let _ = plugin_dispatch_log();
|
||||
let tab = app.active_tab;
|
||||
|
||||
let mut host = super::super::plugin_host::HostSession::new(&mut app, tab);
|
||||
let handle = host.add_entity(EntityType::Point(Point::new()));
|
||||
host.set_reference_only(handle, true).expect("mark");
|
||||
drop(host);
|
||||
|
||||
let _ = plugin_dispatch_log();
|
||||
app.undo_steps(1);
|
||||
assert!(
|
||||
!app.tabs[tab].scene.is_reference_only(handle),
|
||||
"undo reverted the designation"
|
||||
);
|
||||
let log = plugin_dispatch_log();
|
||||
assert_eq!(
|
||||
log.len(),
|
||||
0,
|
||||
"history replay itself emits no plugin notifications: {log:?}"
|
||||
);
|
||||
|
||||
app.redo_steps(1);
|
||||
assert!(app.tabs[tab].scene.is_reference_only(handle));
|
||||
let log = plugin_dispatch_log();
|
||||
assert_eq!(log.len(), 0, "redo replay is equally silent: {log:?}");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn dispatching_without_loaded_plugins_leaves_the_app_state_untouched() {
|
||||
// The default `on_notification` is a no-op; with no plugin processes
|
||||
// loaded, a full boundary pass must not disturb the host.
|
||||
let mut app = super::OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
let epoch = app.tabs[i].scene.geometry_epoch;
|
||||
let selected: Vec<codec::Handle> = app.tabs[i].scene.selected.iter().copied().collect();
|
||||
|
||||
let _ = plugin_dispatch_log();
|
||||
app.notify_plugins_selection_changed();
|
||||
app.notify_plugins_document_changed();
|
||||
app.notify_plugins_document_opened();
|
||||
let log = plugin_dispatch_log();
|
||||
|
||||
assert_eq!(app.tabs[i].scene.geometry_epoch, epoch);
|
||||
assert_eq!(
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.selected
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<_>>(),
|
||||
selected
|
||||
);
|
||||
assert!(
|
||||
log.iter().all(|entry| !entry.starts_with("DocumentOpened")),
|
||||
"a start-tab-free app does not announce spurious opens: {log:?}"
|
||||
);
|
||||
}
|
||||
use crate::app::OpenCADStudio;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
|
||||
@@ -463,6 +463,9 @@ impl OpenCADStudio {
|
||||
self.tabs[i].current_path = None;
|
||||
self.tabs[i].is_start = false;
|
||||
self.tabs[i].dirty = true;
|
||||
// A whole document just became resident in the tab (API v9): mint a
|
||||
// fresh generation so the boundary announces `DocumentOpened` once.
|
||||
self.tabs[i].note_document_loaded();
|
||||
Ok(purged)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1118,6 +1118,12 @@ pub(super) struct OpenCADStudio {
|
||||
/// boundaries as well as after plugin-initiated writes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_plugin_document: Option<(u64, u64)>,
|
||||
/// `(tab id, document generation)` last dispatched as `DocumentOpened` —
|
||||
/// a tab's generation changes only when a whole document replaced its
|
||||
/// content, so the opened notification fires once per load, never per
|
||||
/// edit (API v9).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_plugin_document_opened: Option<(u64, u64)>,
|
||||
/// External add-on packages found in the plugins folder, refreshed when the
|
||||
/// Plugin Manager opens.
|
||||
external_plugins: Vec<crate::plugin::external::ExternalPlugin>,
|
||||
@@ -4735,6 +4741,8 @@ impl OpenCADStudio {
|
||||
last_plugin_selection: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_plugin_document: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_plugin_document_opened: None,
|
||||
external_plugins: Vec::new(),
|
||||
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
|
||||
plugin_load_errors: rustc_hash::FxHashMap::default(),
|
||||
|
||||
@@ -142,6 +142,11 @@ impl<'a> HostSession<'a> {
|
||||
let key = (self.tab_id(), self.app.tabs[self.tab].scene.geometry_epoch);
|
||||
if self.app.last_plugin_document != Some(key) {
|
||||
v4_support::publish_drawing_changed(key.0, key.1);
|
||||
// The plain `DocumentChanged` follows the same immediate
|
||||
// publish contract (API v9): a plugin-initiated write settles
|
||||
// here, so native plugins' `on_notification` sees it now —
|
||||
// the message boundary's fingerprint guard then dedupes.
|
||||
v4_support::publish_document_changed(key.1);
|
||||
}
|
||||
v4_support::publish_document_view_v4(self.tab_id(), doc);
|
||||
self.app.last_plugin_document =
|
||||
@@ -3151,7 +3156,7 @@ impl<'a> HostSession<'a> {
|
||||
/// through this session. The designation is a scene-scoped in-memory set
|
||||
/// keyed by handle: nothing is written to the saved DWG/DXF, the set dies
|
||||
/// with the tab, and a reloaded drawing starts undesignated — the owning
|
||||
/// plugin re-marks its entities when the document opens, which makes the
|
||||
/// plugin re-marks on the `DocumentOpened` notification, which makes the
|
||||
/// in-memory designation fully equivalent to a persisted one for its use
|
||||
/// case.
|
||||
///
|
||||
|
||||
@@ -2244,6 +2244,10 @@ impl OpenCADStudio {
|
||||
.set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1);
|
||||
}
|
||||
self.opening.take();
|
||||
// The document and its derived caches have settled: mark the tab's
|
||||
// generation so the next message boundary announces `DocumentOpened`
|
||||
// to native plugins exactly once for this load (API v9).
|
||||
self.tabs[i].note_document_loaded();
|
||||
// The tab list just changed (and the active tab with it): remember
|
||||
// it for the next boot's restore flow (web session memory).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -308,6 +308,11 @@ impl OpenCADStudio {
|
||||
self.last_plugin_selection = Some(key);
|
||||
let handles = self.tabs[i].scene.selected_handles_in_order();
|
||||
crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles.clone());
|
||||
// Same guarded boundary, second surface (API v9): the plain
|
||||
// `SelectionChanged` reaches every loaded native plugin through
|
||||
// `BuiltinPlugin::on_notification`, without a shared-view
|
||||
// subscription.
|
||||
crate::plugin::v4_support::publish_selection_changed(handles.clone());
|
||||
self.control_record_selection_event(&handles);
|
||||
}
|
||||
|
||||
@@ -327,6 +332,35 @@ impl OpenCADStudio {
|
||||
self.last_plugin_document = Some(key);
|
||||
crate::plugin::v4_support::publish_drawing_changed(tab.id, tab.scene.geometry_epoch);
|
||||
crate::plugin::v4_support::publish_document_view_v4(tab.id, &tab.scene.document);
|
||||
// Same guarded boundary, second surface (API v9): the plain
|
||||
// `DocumentChanged` carries the settled geometry epoch to every
|
||||
// loaded native plugin through `BuiltinPlugin::on_notification`.
|
||||
crate::plugin::v4_support::publish_document_changed(tab.scene.geometry_epoch);
|
||||
}
|
||||
|
||||
/// Announce that a whole document became resident in the active tab —
|
||||
/// opened from file, created from a template, or loaded by a system open
|
||||
/// (API v9). Keyed on `(tab id, document generation)`, which changes only
|
||||
/// when a tab's content is wholesale replaced, so an edit can never fire
|
||||
/// it twice and a reload fires it exactly once. Part of the same
|
||||
/// post-message boundary as the selection and document dispatches: runs
|
||||
/// after the mutation settles and pending history scopes closed, never
|
||||
/// inside history replay or interactive picking.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn notify_plugins_document_opened(&mut self) {
|
||||
if self.active_tab >= self.tabs.len() {
|
||||
return;
|
||||
}
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].is_start {
|
||||
return;
|
||||
}
|
||||
let key = (self.tabs[i].id, self.tabs[i].document_generation);
|
||||
if self.last_plugin_document_opened == Some(key) {
|
||||
return;
|
||||
}
|
||||
self.last_plugin_document_opened = Some(key);
|
||||
crate::plugin::v4_support::publish_document_opened(self.tabs[i].id);
|
||||
}
|
||||
|
||||
pub fn update(&mut self, msg: Message) -> Task<Message> {
|
||||
@@ -540,6 +574,10 @@ impl OpenCADStudio {
|
||||
self.notify_plugins_selection_changed();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.notify_plugins_document_changed();
|
||||
// A message may also have installed a whole document into a tab (file
|
||||
// open, template new); announce it on the same boundary (API v9).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.notify_plugins_document_opened();
|
||||
// OTRACK acquires tracking points only while a command or grip drag is
|
||||
// running; drop them once neither is active so the temporary tracking
|
||||
// points / vectors disappear when the command ends (issue #64). A grip
|
||||
|
||||
@@ -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(¬ification);
|
||||
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()))
|
||||
}
|
||||
Reference in new issue
Block a user