Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Src/Common/Controls/DetailControls/DataTree.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4404,8 +4404,8 @@ public void Init(Mediator mediator, PropertyTable propertyTable, XmlNode configu
if (PersistenceProvder != null)
RestorePreferences();

Subscriber.Subscribe(EventConstants.PostponePropChanged, PostponePropChanged);
Subscriber.Subscribe(EventConstants.JumpToField, JumpToField);
Subscriber.Subscribe(EventConstants.PostponePropChanged, PostponePropChanged, m_propertyTable.GetWindow());
Subscriber.Subscribe(EventConstants.JumpToField, JumpToField, m_propertyTable.GetWindow());
}

public IxCoreColleague[] GetMessageTargets()
Expand Down
4 changes: 2 additions & 2 deletions Src/Common/Controls/DetailControls/GhostStringSlice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -459,12 +459,12 @@ private void SwitchToReal()
int hvoNewObj;
try
{
Publisher.Publish(new PublisherParameterObject(EventConstants.PostponePropChanged, false));
Publisher.Publish(new PublisherParameterObject(EventConstants.PostponePropChanged, false, datatree.PropTable?.GetWindow()));
hvoNewObj = MakeRealObject(tssTyped);
}
finally
{
Publisher.Publish(new PublisherParameterObject(EventConstants.PostponePropChanged, true));
Publisher.Publish(new PublisherParameterObject(EventConstants.PostponePropChanged, true, datatree.PropTable?.GetWindow()));
}

// Now try to make a suitable selection in the slice that replaces this.
Expand Down
2 changes: 1 addition & 1 deletion Src/Common/Controls/XMLViews/BrowseViewer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3298,7 +3298,7 @@ public virtual void Init(Mediator mediator, PropertyTable propertyTable, XmlNode
m_xbv.Init(mediator, propertyTable, configurationParameters);
m_xbv.AccessibleName = "BrowseViewer";
m_mediator = mediator;
Subscriber.Subscribe(EventConstants.RemoveFilters, RemoveFilters);
Subscriber.Subscribe(EventConstants.RemoveFilters, RemoveFilters, propertyTable.GetWindow());
}
/// <summary>
/// Should not be called if disposed.
Expand Down
4 changes: 2 additions & 2 deletions Src/Common/Controls/XMLViews/XmlBrowseRDEView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ public override void Init(XmlNode nodeSpec, int hvoRoot, int fakeFlid,
// Use the ones in fakeFlid, and any we create.
base.Init(nodeSpec, hvoRoot, fakeFlid, cache, mediator, bv);

Subscriber.Subscribe(EventConstants.DeleteRecord, DeleteRecord);
Subscriber.Subscribe(EventConstants.ConsideringClosing, ConsideringClosing);
Subscriber.Subscribe(EventConstants.DeleteRecord, DeleteRecord, m_propertyTable.GetWindow());
Subscriber.Subscribe(EventConstants.ConsideringClosing, ConsideringClosing, m_propertyTable.GetWindow());
}

#endregion Construction, initialization, and disposal.
Expand Down
6 changes: 3 additions & 3 deletions Src/Common/Controls/XMLViews/XmlBrowseViewBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2085,9 +2085,9 @@ public override void Init(Mediator mediator, PropertyTable propertyTable, XmlNod

SetSelectedRowHighlighting();//read the property table

Subscriber.Subscribe(EventConstants.SaveScrollPosition, SaveScrollPosition);
Subscriber.Subscribe(EventConstants.RestoreScrollPosition, RestoreScrollPosition);
Subscriber.Subscribe(EventConstants.PrepareToRefresh, PrepareToRefresh);
Subscriber.Subscribe(EventConstants.SaveScrollPosition, SaveScrollPosition, m_propertyTable.GetWindow());
Subscriber.Subscribe(EventConstants.RestoreScrollPosition, RestoreScrollPosition, m_propertyTable.GetWindow());
Subscriber.Subscribe(EventConstants.PrepareToRefresh, PrepareToRefresh, m_propertyTable.GetWindow());
}

#endregion XCore Colleague overrides
Expand Down
2 changes: 1 addition & 1 deletion Src/Common/Controls/XMLViews/XmlBrowseViewBaseVc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1767,7 +1767,7 @@ public override void DoHotLinkAction(string strData, ISilDataAccess sda)
FwLinkArgs linkArgs = new FwLinkArgs(url);
linkArgs.DisplayErrorMsg = false;
var retObj = new ReturnObject(linkArgs);
Publisher.Publish(new PublisherParameterObject(EventConstants.FollowLink, retObj));
Publisher.Publish(new PublisherParameterObject(EventConstants.FollowLink, retObj, m_xbv?.m_bv?.PropTable?.GetWindow()));
if (retObj.ReturnValue)
return;
}
Expand Down
2 changes: 1 addition & 1 deletion Src/Common/FieldWorks/FieldWorks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1849,7 +1849,7 @@ private static ProjectId ShowWelcomeDialog(FwAppArgs args, FwApp startingApp, Pr
s_projectId = projectToTry; // Window is open on this project, we must not try to initialize it again.
if (Form.ActiveForm is IxWindow mainWindow)
{
Publisher.Publish(new PublisherParameterObject(EventConstants.SFMImport));
Publisher.Publish(new PublisherParameterObject(EventConstants.SFMImport, null, mainWindow));
}
else
{
Expand Down
32 changes: 27 additions & 5 deletions Src/Common/FwUtils/EndOfActionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ namespace SIL.FieldWorks.Common.FwUtils
public sealed class EndOfActionManager
{
private bool m_initialized = false;
private readonly Dictionary<string, object> m_events = new Dictionary<string, object>();
// The events are grouped by message, and then by scope within each message. At most a message
// is queued once per scope (last one wins). Note: Null is a valid scope, it follows the same
// rules; queued once and last one wins, but null scope is unique in that the message is delivered
// to all subscribers regardless of their scope (null or not null).
private readonly Dictionary<string, List<PublisherParameterObject>> m_events = new Dictionary<string, List<PublisherParameterObject>>();

/// <summary>
/// Returns the EndOfAction event that is currently being published.
Expand Down Expand Up @@ -51,8 +55,23 @@ internal void AddEvent(PublisherParameterObject publisherParameterObject)
}
}

// Add the dictionary entry if the key is not present. Overwrite the value if the key is present.
m_events[publisherParameterObject.Message] = publisherParameterObject.Data;
// The whole parameter object is kept so the delivery scope survives until the idle publish.
// Group by both message and scope: overwrite an already-queued publish only if it has the
// same scope; publishes of the same message from different scopes are all delivered.
if (!m_events.TryGetValue(publisherParameterObject.Message, out var queued))
{
queued = new List<PublisherParameterObject>();
m_events.Add(publisherParameterObject.Message, queued);
}
var sameScopeIndex = queued.FindIndex(e => ReferenceEquals(e.Scope, publisherParameterObject.Scope));
if (sameScopeIndex >= 0)
{
queued[sameScopeIndex] = publisherParameterObject;
}
else
{
queued.Add(publisherParameterObject);
}
}

/// Should be private, but is public to support testing.
Expand All @@ -63,11 +82,14 @@ public bool IdleEndOfAction(object _)
{
foreach (var orderedEvent in EndOfActionOrder.Order)
{
if (m_events.TryGetValue(orderedEvent, out object data))
if (m_events.TryGetValue(orderedEvent, out var queuedEvents))
{
m_events.Remove(orderedEvent);
CurrentEndOfActionEvent = orderedEvent;
FwUtils.Publisher.Publish(new PublisherParameterObject(orderedEvent, data));
foreach (var publisherParameterObject in queuedEvents)
{
FwUtils.Publisher.Publish(publisherParameterObject);
}
CurrentEndOfActionEvent = null;
}

Expand Down
216 changes: 216 additions & 0 deletions Src/Common/FwUtils/FwUtilsTests/PubSubSystemTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,147 @@ public void Test_Two_Subscribers_For_MessageOneHandling()
Assert.That(subscriber2.One, Is.True); // Did not change.
}

/// <summary>
/// A scoped publish goes to same-scope and unscoped subscribers, but not to
/// subscribers with a different scope.
/// </summary>
[Test]
public void Scoped_Publish_Delivers_Only_To_Matching_Scope_And_Unscoped_Subscribers()
{
// Set up.
var scopeA = new TestPubSubScope();
var subscriberA = new ScopeAwareSubscriber(scopeA) { One = true };
var subscriberB = new ScopeAwareSubscriber(new TestPubSubScope()) { One = true };
var unscopedSubscriber = new ScopeAwareSubscriber(null) { One = true };
subscriberA.DoSubscriptions();
subscriberB.DoSubscriptions();
unscopedSubscriber.DoSubscriptions();

// Run test.
FwUtils.Publisher.Publish(new PublisherParameterObject("MessageOne", false, scopeA));
Assert.That(subscriberA.One, Is.False); // Same scope: delivered.
Assert.That(subscriberB.One, Is.True); // Different scope: not delivered.
Assert.That(unscopedSubscriber.One, Is.False); // Unscoped subscriber: delivered.

subscriberA.DoUnsubscriptions();
subscriberB.DoUnsubscriptions();
unscopedSubscriber.DoUnsubscriptions();
}

/// <summary>
/// An unscoped publish is process-wide: every subscriber receives it, scoped or not.
/// </summary>
[Test]
public void Unscoped_Publish_Delivers_To_Scoped_And_Unscoped_Subscribers()
{
// Set up.
var scopedSubscriber = new ScopeAwareSubscriber(new TestPubSubScope()) { One = true };
var unscopedSubscriber = new ScopeAwareSubscriber(null) { One = true };
scopedSubscriber.DoSubscriptions();
unscopedSubscriber.DoSubscriptions();

// Run test.
FwUtils.Publisher.Publish(new PublisherParameterObject("MessageOne", false));
Assert.That(scopedSubscriber.One, Is.False); // Delivered.
Assert.That(unscopedSubscriber.One, Is.False); // Delivered.

scopedSubscriber.DoUnsubscriptions();
unscopedSubscriber.DoUnsubscriptions();
}

/// <summary>
/// The scope must survive the EndOfActionManager round trip through the idle queue.
/// </summary>
[Test]
public void Scope_Is_Preserved_Through_PublishAtEndOfAction()
{
// Set up.
var scopeA = new TestPubSubScope();
var subscriberA = new ScopeAwareSubscriber(scopeA) { Two = int.MinValue };
var subscriberB = new ScopeAwareSubscriber(new TestPubSubScope()) { Two = int.MinValue };
subscriberA.DoSubscriptions();
subscriberB.DoSubscriptions();

FwUtils.Publisher.PublishAtEndOfAction(new PublisherParameterObject(EventConstants.SelectionChanged, int.MaxValue, scopeA));

// Nothing is delivered until the idle queue drains.
Assert.That(subscriberA.Two, Is.EqualTo(int.MinValue));
Assert.That(subscriberB.Two, Is.EqualTo(int.MinValue));

// SUT - Process the EndOfActionManager IdleQueue.
FwUtils.Publisher.EndOfActionManager.IdleEndOfAction(null);

Assert.That(subscriberA.Two, Is.EqualTo(int.MaxValue)); // Same scope: delivered.
Assert.That(subscriberB.Two, Is.EqualTo(int.MinValue)); // Different scope: not delivered.

subscriberA.DoUnsubscriptions();
subscriberB.DoUnsubscriptions();
}

/// <summary>
/// EndOfAction coalescing is per (message, scope): the same message queued from two
/// scopes is delivered to both, while a re-publish from the same scope overwrites.
/// </summary>
[Test]
public void PublishAtEndOfAction_Coalesces_Per_Scope()
{
// Set up.
var scopeA = new TestPubSubScope();
var scopeB = new TestPubSubScope();
var subscriberA = new ScopeAwareSubscriber(scopeA) { Two = int.MinValue };
var subscriberB = new ScopeAwareSubscriber(scopeB) { Two = int.MinValue };
subscriberA.DoSubscriptions();
subscriberB.DoSubscriptions();

FwUtils.Publisher.PublishAtEndOfAction(new PublisherParameterObject(EventConstants.SelectionChanged, 1, scopeA));
FwUtils.Publisher.PublishAtEndOfAction(new PublisherParameterObject(EventConstants.SelectionChanged, 2, scopeB));
// Same scope again: coalesces with (overwrites) the first scopeA publish.
FwUtils.Publisher.PublishAtEndOfAction(new PublisherParameterObject(EventConstants.SelectionChanged, 3, scopeA));

// SUT - Process the EndOfActionManager IdleQueue.
FwUtils.Publisher.EndOfActionManager.IdleEndOfAction(null);

Assert.That(subscriberA.Two, Is.EqualTo(3)); // Latest scopeA publish won.
Assert.That(subscriberB.Two, Is.EqualTo(2)); // scopeB publish survived independently.

subscriberA.DoUnsubscriptions();
subscriberB.DoUnsubscriptions();
}

/// <summary>
/// Prefix subscriptions honor the same scope rule as specific subscriptions:
/// scoped publishes skip other-scope prefix subscribers and reach same-scope and
/// unscoped ones; non-matching prefixes are never delivered.
/// </summary>
[Test]
public void Prefix_Subscriptions_Honor_Scope()
{
// Set up.
var scopeA = new TestPubSubScope();
var subscriberA = new PrefixScopeAwareSubscriber(scopeA);
var subscriberB = new PrefixScopeAwareSubscriber(new TestPubSubScope());
var unscopedSubscriber = new PrefixScopeAwareSubscriber(null);
subscriberA.DoSubscriptions();
subscriberB.DoSubscriptions();
unscopedSubscriber.DoSubscriptions();

// Run test.
FwUtils.Publisher.Publish(new PublisherParameterObject("PrefixedMessageOne", 7, scopeA));
Assert.That(subscriberA.LastMessage, Is.EqualTo("PrefixedMessageOne")); // Same scope: delivered.
Assert.That(subscriberB.LastMessage, Is.Null); // Different scope: not delivered.
Assert.That(unscopedSubscriber.LastMessage, Is.EqualTo("PrefixedMessageOne")); // Unscoped subscriber: delivered.

subscriberA.LastMessage = null;
unscopedSubscriber.LastMessage = null;
FwUtils.Publisher.Publish(new PublisherParameterObject("UnrelatedMessage", 7, scopeA));
Assert.That(subscriberA.LastMessage, Is.Null); // Prefix does not match: not delivered.
Assert.That(unscopedSubscriber.LastMessage, Is.Null);

subscriberA.DoUnsubscriptions();
subscriberB.DoUnsubscriptions();
unscopedSubscriber.DoUnsubscriptions();
}

[Test]
[TestCase(null)]
[TestCase("")]
Expand Down Expand Up @@ -553,6 +694,81 @@ private void MessageTwoHandler(object newValue)
}
}

private sealed class TestPubSubScope : IPubSubScope
{
}

private sealed class PrefixScopeAwareSubscriber
{
private readonly IPubSubScope _scope;

internal PrefixScopeAwareSubscriber(IPubSubScope scope)
{
_scope = scope;
}

internal string LastMessage { get; set; }

/// <summary>
/// This is the subscribed prefix handler for messages starting with "PrefixedMessage".
/// </summary>
private void PrefixedMessageHandler(string message, object newValue)
{
LastMessage = message;
}

internal void DoSubscriptions()
{
FwUtils.Subscriber.PrefixSubscribe("PrefixedMessage", PrefixedMessageHandler, _scope);
}

internal void DoUnsubscriptions()
{
FwUtils.Subscriber.PrefixUnsubscribe("PrefixedMessage", PrefixedMessageHandler);
}
}

private sealed class ScopeAwareSubscriber
{
private readonly IPubSubScope _scope;

internal ScopeAwareSubscriber(IPubSubScope scope)
{
_scope = scope;
}

internal bool One { get; set; }
internal int Two { get; set; }

/// <summary>
/// This is the subscribed message handler for "MessageOne" message.
/// </summary>
private void MessageOneHandler(object newValue)
{
One = (bool)newValue;
}

/// <summary>
/// This is the subscribed message handler for the SelectionChanged message.
/// </summary>
private void SelectionChangedHandler(object newValue)
{
Two = (int)newValue;
}

internal void DoSubscriptions()
{
FwUtils.Subscriber.Subscribe("MessageOne", MessageOneHandler, _scope);
FwUtils.Subscriber.Subscribe(EventConstants.SelectionChanged, SelectionChangedHandler, _scope);
}

internal void DoUnsubscriptions()
{
FwUtils.Subscriber.Unsubscribe("MessageOne", MessageOneHandler);
FwUtils.Subscriber.Unsubscribe(EventConstants.SelectionChanged, SelectionChangedHandler);
}
}

private sealed class EndOfAction_MultipleSubscriber
{
internal string First { get; set; }
Expand Down
32 changes: 32 additions & 0 deletions Src/Common/FwUtils/IPubSubScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) 2026 SIL International
// This software is licensed under the LGPL, version 2.1 or later
// (http://www.gnu.org/licenses/lgpl-2.1.html)

namespace SIL.FieldWorks.Common.FwUtils
{
/// <summary>
/// Marker interface for objects that scope Pub/Sub delivery.
///
/// The Publisher and Subscriber are process-wide singletons, but most messages are
/// window-scoped: the legacy Mediator they replace delivered only within one main window.
/// To reproduce that, a subscriber passes its main window when subscribing, and a publisher
/// passes the same window in its PublisherParameterObject; the Publisher then delivers a
/// scoped publish only to subscribers with the identical (reference-equal) scope.
/// A Publisher with a null scope will go to all the message subscribers and a Subscriber
/// with a null scope will receive all the messages, regardless of them being published
/// with or without a scope.
/// Null is for publishers with no window context (e.g. app bootstrap code) and for
/// messages that must reach a window the publisher cannot name (e.g. Send/Receive messages
/// aimed at the project's reopened instance). (Process-wide delivery is a Pub/Sub-era
/// capability, not preserved Mediator behavior. The Mediator had no all-windows broadcast;
/// instead its app-wide coordination was accomplished by iterating FwApp.MainWindows directly.)
///
/// The scope IS the main window: the XCore IxWindow interface extends this one, so main
/// windows pass <c>this</c> and everything else obtains the window via
/// <c>PropertyTable.GetWindow()</c>.
/// Do not implement this interface on anything that is not a main window.
/// </summary>
public interface IPubSubScope
{
}
}
Loading
Loading