diff --git a/Src/xWorks/Avalonia/Hosting/CompositeMenuAuthority.cs b/Src/xWorks/Avalonia/Hosting/CompositeMenuAuthority.cs
new file mode 100644
index 0000000000..bd5ff2ac69
--- /dev/null
+++ b/Src/xWorks/Avalonia/Hosting/CompositeMenuAuthority.cs
@@ -0,0 +1,45 @@
+// 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)
+
+using System;
+using SIL.FieldWorks.Common.FwAvalonia.Detail;
+using XCore;
+
+namespace SIL.FieldWorks.XWorks
+{
+ ///
+ /// One authority over several: a menu id is answered by the first member that owns it.
+ ///
+ internal sealed class CompositeMenuAuthority : IDetailMenuAuthority
+ {
+ private readonly IDetailMenuAuthority[] _members;
+
+ public CompositeMenuAuthority(params IDetailMenuAuthority[] members)
+ {
+ _members = members ?? throw new ArgumentNullException(nameof(members));
+ if (Array.IndexOf(_members, null) >= 0)
+ throw new ArgumentException("A member authority is null.", nameof(members));
+ }
+
+ public bool Owns(string menuId) => Owner(menuId) != null;
+
+ public DetailMenuItem Build(string menuId, ChoiceBase leaf)
+ {
+ var owner = Owner(menuId);
+ if (owner == null)
+ throw new InvalidOperationException(string.Format("No authority owns menu '{0}'.", menuId));
+ return owner.Build(menuId, leaf);
+ }
+
+ private IDetailMenuAuthority Owner(string menuId)
+ {
+ foreach (var member in _members)
+ {
+ if (member.Owns(menuId))
+ return member;
+ }
+ return null;
+ }
+ }
+}
diff --git a/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs b/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs
new file mode 100644
index 0000000000..52806d1a9f
--- /dev/null
+++ b/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs
@@ -0,0 +1,131 @@
+// 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)
+
+using System;
+using SIL.FieldWorks.Common.FwAvalonia.Detail;
+using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition;
+using XCore;
+
+namespace SIL.FieldWorks.XWorks
+{
+ ///
+ /// The native authority for the per-object menu every detail row merges (Field Visibility,
+ /// Move Field, Help) and for the empty Help menu most rows bind. Field Visibility and Move
+ /// Field act on the row's node in the project override layer; Help opens the row's own
+ /// topic. A row whose override target cannot be located keeps those items, disabled.
+ ///
+ internal sealed class ObjectMenuAuthority : IDetailMenuAuthority
+ {
+ internal const string MenuId = RecordEditView.ObjectMenuId;
+ internal const string HelpMenuId = "mnuDataTree-Help";
+ internal const string AlwaysVisibleCommandId = "CmdAlwaysVisible";
+ internal const string IfDataCommandId = "CmdIfData";
+ internal const string NormallyHiddenCommandId = "CmdNormallyHidden";
+ internal const string MoveFieldUpCommandId = "CmdDataTree-MoveFieldUp";
+ internal const string MoveFieldDownCommandId = "CmdDataTree-MoveFieldDown";
+ internal const string HelpCommandId = "CmdDataTree-Help";
+
+ private readonly Lazy _target;
+ private readonly Func _fieldVisibility;
+ private readonly Func _moveField;
+ private readonly Lazy _helpTopic;
+ private readonly Action _showHelp;
+
+ /// Creates the authority for one row's menu.
+ /// The row.
+ /// The row's override target, or null when it cannot be
+ /// located; asked once, on the first Field Visibility or Move Field leaf.
+ /// Builds a Field Visibility item from (label, target,
+ /// visibility).
+ /// Builds a Move Field item from (label, target, up).
+ /// The row's help topic when the help provider has it, else
+ /// null; asked once.
+ /// Opens a help topic.
+ public ObjectMenuAuthority(DetailField field, Func locateTarget,
+ Func fieldVisibility,
+ Func moveField,
+ Func helpTopic, Action showHelp)
+ {
+ if (field == null)
+ throw new ArgumentNullException(nameof(field));
+ if (locateTarget == null)
+ throw new ArgumentNullException(nameof(locateTarget));
+ if (helpTopic == null)
+ throw new ArgumentNullException(nameof(helpTopic));
+ _fieldVisibility = fieldVisibility ?? throw new ArgumentNullException(nameof(fieldVisibility));
+ _moveField = moveField ?? throw new ArgumentNullException(nameof(moveField));
+ _showHelp = showHelp ?? throw new ArgumentNullException(nameof(showHelp));
+ _target = new Lazy(() => locateTarget(field));
+ _helpTopic = new Lazy(() => helpTopic(field));
+ }
+
+ public bool Owns(string menuId)
+ => string.Equals(menuId, MenuId, StringComparison.Ordinal)
+ || string.Equals(menuId, HelpMenuId, StringComparison.Ordinal);
+
+ public DetailMenuItem Build(string menuId, ChoiceBase leaf)
+ {
+ if (leaf == null)
+ throw new ArgumentNullException(nameof(leaf));
+ var label = XCoreMenuBridge.StripAccelerator(leaf.Label);
+ switch (leaf.HelpId)
+ {
+ case AlwaysVisibleCommandId:
+ return VisibilityItem(label, ViewVisibility.Always);
+ case IfDataCommandId:
+ return VisibilityItem(label, ViewVisibility.IfData);
+ case NormallyHiddenCommandId:
+ return VisibilityItem(label, ViewVisibility.Never);
+ case MoveFieldUpCommandId:
+ return MoveItem(label, up: true);
+ case MoveFieldDownCommandId:
+ return MoveItem(label, up: false);
+ case HelpCommandId:
+ return HelpItem(label);
+ default:
+ throw new InvalidOperationException(string.Format(
+ "Menu '{0}' has a leaf '{1}' this authority does not answer.", menuId, leaf.HelpId));
+ }
+ }
+
+ // Offered on every row; without a located target it is disabled rather than guessed.
+ private DetailMenuItem VisibilityItem(string label, ViewVisibility visibility)
+ => _target.Value == null ? Disabled(label) : _fieldVisibility(label, _target.Value, visibility);
+
+ private DetailMenuItem MoveItem(string label, bool up)
+ => _target.Value == null ? Disabled(label) : _moveField(label, _target.Value, up);
+
+ // Hidden when the help provider has no topic for the row, as WinForms hides it.
+ private DetailMenuItem HelpItem(string label)
+ {
+ var topic = _helpTopic.Value;
+ if (topic == null)
+ return null;
+ return new DetailMenuItem(label, isEnabled: true, isChecked: false, children: null,
+ execute: () => _showHelp(topic));
+ }
+
+ private static DetailMenuItem Disabled(string label)
+ => new DetailMenuItem(label, isEnabled: false, isChecked: false, children: null, execute: null);
+ }
+
+ ///
+ /// A row's node in its own compiled model with the current override applied: what Field
+ /// Visibility reads its checkmark from and Move Field its enablement.
+ ///
+ internal sealed class OverrideTarget
+ {
+ public OverrideTarget(string templateId, ViewNodeLocation location)
+ {
+ TemplateId = templateId ?? throw new ArgumentNullException(nameof(templateId));
+ Location = location ?? throw new ArgumentNullException(nameof(location));
+ }
+
+ /// The row's template id, without its runtime suffix.
+ public string TemplateId { get; }
+
+ /// The node's position and visibility among its siblings.
+ public ViewNodeLocation Location { get; }
+ }
+}
diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs
index 390481f3a8..890000d728 100644
--- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs
+++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs
@@ -515,31 +515,27 @@ private void OnDetailMenuRequested(DetailMenuRequest request)
// Only ids without a native authority need the hidden command adapter. An adapter
// failure must not suppress the menu: its items disable, the rest still works.
- var authority = CreateReorderVectorAuthority(request);
- if (!XCoreMenuBridge.OwnsAll(authority, idArray))
- {
- try
- {
- EnsureMenuCommandAdapter(request.Field.ObjectHvo, request.Field.Field);
- }
- catch (Exception adapterError)
- {
- Logger.WriteError("Detail menu command adapter failed; menu items that need "
- + "the hidden colleague chain will be disabled.", adapterError);
- }
- }
+ var authority = CreateMenuAuthority(request);
+ var ownsAll = XCoreMenuBridge.OwnsAll(authority, idArray);
+ if (!ownsAll)
+ SyncMenuCommandAdapter(request.Field);
// Render the SAME xCore menu natively in Avalonia -- identical items,
// enablement, and mediator dispatch; only rendering changes. The WinForms
// adapter menu remains the fallback if materialization fails.
try
{
- // Field Visibility / Move Field retarget to the override layer; other
- // mediator-answered commands keep their dispatch.
- var registry = new OverrideCommandRegistry();
- AddOverrideCommands(registry, request.Field);
- var items = XCoreMenuBridge.CreateMenuItems(window, idArray, registry.TryBuild, null,
- authority);
+ // On the mediator path Field Visibility / Move Field retarget to the override
+ // layer; other mediator-answered commands keep their dispatch. An owned menu
+ // never consults the interceptor, so it is not built.
+ Func interceptor = null;
+ if (!ownsAll)
+ {
+ var registry = new OverrideCommandRegistry();
+ AddOverrideCommands(registry, request.Field);
+ interceptor = registry.TryBuild;
+ }
+ var items = XCoreMenuBridge.CreateMenuItems(window, idArray, interceptor, null, authority);
if (items.Count > 0)
{
// A keyboard-opened menu anchors under the row it came from; a
@@ -555,6 +551,10 @@ private void OnDetailMenuRequested(DetailMenuRequest request)
nativeMenuError);
}
+ // The adapter menu answers from the hidden tree's current slice, which an owned
+ // menu never pointed at this row.
+ if (ownsAll)
+ SyncMenuCommandAdapter(request.Field);
window.ShowContextMenu(idArray, AdapterMenuScreenPoint(request), null, null);
}
catch (Exception e)
@@ -597,21 +597,20 @@ private ViewDefinitionOverrideStore ViewOverrideStore
///
/// The help topic of a detail row: its when set,
- /// else one generated from the row's field and object and the current tool. Null when
- /// the row carries nothing to generate from.
+ /// else one generated from the row's field and object and the current tool. A row with
+ /// no object generates from its field and label alone, so every row ends at a topic,
+ /// the generic one at worst.
///
internal string ResolveHelpTopic(DetailField field)
{
if (field == null)
return null;
var source = field.HelpTopicSource;
- if (string.IsNullOrEmpty(field.HelpTopicId) && source == null)
- return null;
var provider = m_propertyTable.GetValue("HelpTopicProvider");
var subject = new HelpTopicSubject
{
- FieldName = source?.FieldName,
- Label = source?.Label,
+ FieldName = source?.FieldName ?? field.Field,
+ Label = source?.Label ?? field.Label,
ClassName = source?.ClassName,
OwnerClassName = source?.OwnerClassName,
SortKey = source?.SortKey,
@@ -674,6 +673,66 @@ internal void AddOverrideCommands(OverrideCommandRegistry registry, DetailField
private static string LabelOf(UIItemDisplayProperties display)
=> XCoreMenuBridge.StripAccelerator(display.Text);
+ ///
+ /// The native authorities for the request's row: the reorder-vector menu and the shared
+ /// per-object and Help menus, so a label menu made only of those ids needs nothing from
+ /// the hidden command adapter.
+ ///
+ internal IDetailMenuAuthority CreateMenuAuthority(DetailMenuRequest request)
+ => new CompositeMenuAuthority(CreateReorderVectorAuthority(request),
+ CreateObjectMenuAuthority(request.Field));
+
+ /// The native authority for the row's per-object and Help menus.
+ internal IDetailMenuAuthority CreateObjectMenuAuthority(DetailField field)
+ => new ObjectMenuAuthority(field, LocateOverrideTarget,
+ fieldVisibility: (label, target, visibility) =>
+ VisibilityItem(label, field, target.TemplateId, target.Location, visibility),
+ moveField: (label, target, up) => MoveItem(label, field, target.Location, up),
+ helpTopic: KnownHelpTopic,
+ showHelp: ShowDetailHelp);
+
+ // The row's override target, or null (with the reason logged) when it cannot be located.
+ // A failure disables the row's field commands rather than failing the whole menu.
+ private OverrideTarget LocateOverrideTarget(DetailField field)
+ {
+ try
+ {
+ return TryLocateOverrideTarget(field, out var templateId, out var location)
+ ? new OverrideTarget(templateId, location)
+ : null;
+ }
+ catch (Exception e)
+ {
+ Logger.WriteError("Locating the row's override target failed; its Field Visibility and "
+ + "Move Field commands are disabled.", e);
+ return null;
+ }
+ }
+
+ ///
+ /// The row's help topic when the help provider has it, else null. A failure hides Help
+ /// rather than failing the whole menu.
+ ///
+ internal string KnownHelpTopic(DetailField field)
+ {
+ try
+ {
+ var topic = ResolveHelpTopic(field);
+ if (topic == null)
+ return null;
+ var provider = m_propertyTable.GetValue("HelpTopicProvider");
+ return provider?.GetHelpString(topic) != null ? topic : null;
+ }
+ catch (Exception e)
+ {
+ Logger.WriteError("Resolving the row's help topic failed; Help is hidden.", e);
+ return null;
+ }
+ }
+
+ private void ShowDetailHelp(string topic)
+ => ShowHelp.ShowHelpTopic(m_propertyTable.GetValue("HelpTopicProvider"), topic);
+
///
/// Locates the row's node in its own compiled model, with the current override applied,
/// so visibility checkmarks and move enablement reflect the live state. False without a
diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs
index dbc261296a..279709f03c 100644
--- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs
+++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs
@@ -11,6 +11,7 @@
using System.Xml;
using NUnit.Framework;
using SIL.FieldWorks.Common.Controls;
+using SIL.FieldWorks.Common.DetailRules;
using SIL.FieldWorks.Common.FwAvalonia;
using SIL.FieldWorks.Common.FwAvalonia.Detail;
using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition;
@@ -652,15 +653,20 @@ public void ReorderVectorAuthority_RejectsALeafItDoesNotAnswer()
}
[Test]
- public void LabelMenu_StillNeedsTheAdapter_WhileTheSharedObjectMenuHasNoAuthority()
+ public void LabelMenu_IsFullyOwned_UnlessItCarriesTheWritingSystemsList()
{
MakeTwoSubentries();
- var field = SubentriesField();
- var authority = m_view.CreateReorderVectorAuthority(LabelMenuRequest(field, NoItem));
-
- Assert.That(XCoreMenuBridge.OwnsAll(authority, new[] { ReorderVectorMenuAuthority.MenuId }), Is.True);
- Assert.That(XCoreMenuBridge.OwnsAll(authority, LabelMenuIds(field)), Is.False,
- "the label menu merges mnuDataTree-Object, which the mediator still answers");
+ var subentries = SubentriesField();
+ var authority = m_view.CreateMenuAuthority(LabelMenuRequest(subentries, NoItem));
+
+ Assert.That(XCoreMenuBridge.OwnsAll(authority, LabelMenuIds(subentries)), Is.True,
+ "the reorder menu and the shared object menu are both answered natively");
+ var helpBound = DetailComposer.Compose(m_entry, Cache).Model.Fields
+ .First(f => f.MenuId == ObjectMenuAuthority.HelpMenuId);
+ Assert.That(XCoreMenuBridge.OwnsAll(authority, LabelMenuIds(helpBound)), Is.True,
+ "a row bound to the empty Help menu merges only owned ids");
+ Assert.That(XCoreMenuBridge.OwnsAll(authority, LabelMenuIds(LexemeFormField())), Is.False,
+ "the multi-string menu's writing-system list still needs the mediator");
Assert.That(XCoreMenuBridge.OwnsAll(null, new[] { ReorderVectorMenuAuthority.MenuId }), Is.False);
}
@@ -810,6 +816,277 @@ public void OwnedMenu_WithAListSubmenu_IsRefused()
"a list-populated submenu has no configured leaves an authority could answer");
}
+ // The per-object menu's native authority: Field Visibility, Move Field and Help answered
+ // from the row and the override layer, so an ordinary row's label menu needs no adapter.
+
+ private static IHelpTopicProvider KnowsEveryTopic => new PatternHelpTopicProvider(id => true);
+
+ private void UseHelpProvider(IHelpTopicProvider provider)
+ {
+ m_propertyTable.SetProperty("HelpTopicProvider", provider, false);
+ m_propertyTable.SetPropertyPersistence("HelpTopicProvider", false);
+ }
+
+ private LegacyDataTree AdapterTree => (LegacyDataTree)GetField(m_view, "m_dataEntryForm");
+
+ // The label menu exactly as OnDetailMenuRequested builds it: the row's authorities for
+ // the ids they own, the override interceptor for the rest.
+ private IReadOnlyList BuildAsTheHostDoes(string[] ids, DetailField field)
+ {
+ var registry = new OverrideCommandRegistry();
+ m_view.AddOverrideCommands(registry, field);
+ var window = m_propertyTable.GetValue("window");
+ return XCoreMenuBridge.CreateMenuItems(window, ids, registry.TryBuild, null,
+ m_view.CreateMenuAuthority(LabelMenuRequest(field, NoItem)));
+ }
+
+ // Whether the adapter's current slice is the WinForms twin of the row: same object and
+ // same field.
+ private bool AdapterTargets(DetailField field)
+ {
+ var slice = AdapterTree.CurrentSlice;
+ if (slice?.Object == null || slice.Object.Hvo != field.ObjectHvo || slice.Flid == 0)
+ return false;
+ var mdc = (IFwMetaDataCacheManaged)Cache.MetaDataCacheAccessor;
+ return mdc.FieldExists(slice.Flid)
+ && string.Equals(mdc.GetFieldName(slice.Flid), field.Field, StringComparison.Ordinal);
+ }
+
+ [Test]
+ public void ObjectMenuAuthority_AnswersEveryLeafOfItsMenus()
+ {
+ var authority = m_view.CreateObjectMenuAuthority(LexemeFormField());
+ var window = m_propertyTable.GetValue("window");
+ var menu = window.GetContextMenuChoiceGroup(new[] { ObjectMenuAuthority.MenuId });
+ menu.PopulateNow(querySubmenuVisibility: false);
+
+ var leaves = Leaves(menu).ToList();
+ Assert.That(leaves.Select(l => l.HelpId), Is.EquivalentTo(new[]
+ {
+ ObjectMenuAuthority.AlwaysVisibleCommandId, ObjectMenuAuthority.IfDataCommandId,
+ ObjectMenuAuthority.NormallyHiddenCommandId, ObjectMenuAuthority.MoveFieldUpCommandId,
+ ObjectMenuAuthority.MoveFieldDownCommandId, ObjectMenuAuthority.HelpCommandId
+ }), "the shipped menu defines exactly the leaves the authority knows");
+ foreach (var leaf in leaves)
+ {
+ Assert.That(() => authority.Build(ObjectMenuAuthority.MenuId, leaf), Throws.Nothing,
+ "the authority does not answer leaf '{0}'", leaf.HelpId);
+ }
+
+ var help = window.GetContextMenuChoiceGroup(new[] { ObjectMenuAuthority.HelpMenuId });
+ help.PopulateNow(querySubmenuVisibility: false);
+ Assert.That(Leaves(help), Is.Empty, "mnuDataTree-Help defines no leaf of its own");
+ Assert.That(authority.Owns(ObjectMenuAuthority.HelpMenuId), Is.True);
+ }
+
+ [Test]
+ public void ObjectMenu_OfAnOrdinaryRow_IsBuiltWithoutTheAdapterOrTheMediator()
+ {
+ MakeTwoSubentries();
+ UseHelpProvider(KnowsEveryTopic);
+ var field = SubentriesField();
+ var ids = LabelMenuIds(field);
+ // No EnsureAdapter: the hidden tree never exists.
+
+ var items = BuildWithSpy(ids, m_view.CreateMenuAuthority(LabelMenuRequest(field, NoItem)), out var asked);
+
+ Assert.That(asked, Is.False, "nothing in the label menu reaches the mediator");
+ var visibility = FindItem(items, "Field Visibility");
+ Assert.That(visibility, Is.Not.Null);
+ Assert.That(visibility.Children.Count(c => c.IsChecked), Is.EqualTo(1),
+ "exactly one visibility is the row's current one");
+ Assert.That(visibility.Children.Select(c => c.Execute), Is.All.Not.Null,
+ "a located row's visibility items all execute");
+ var move = FindItem(items, "Move Field");
+ Assert.That(move?.Children.Select(c => c.Label), Is.EqualTo(new[] { "Move Up", "Move Down" }));
+ Assert.That(FindItem(items, "Help...")?.Execute, Is.Not.Null, "Help opens the row's own topic");
+ }
+
+ // Baseline = the path this replaces: mediator through the adapter, interceptor for the
+ // field commands. An unlocatable row is the agreed exception: its commands disable.
+ [Test]
+ public void ObjectMenu_NativeAuthority_RendersWhatTheInterceptorPathRendered_ForEveryRowTheAdapterCanTarget()
+ {
+ AddSense("second gloss");
+ MakeTwoSubentries();
+ UseHelpProvider(KnowsEveryTopic);
+ var fields = DetailComposer.Compose(m_entry, Cache).Model.Fields
+ .Where(f => f.ObjectHvo != 0 && !string.IsNullOrEmpty(f.Field)).ToList();
+
+ var compared = 0;
+ var untargeted = new List();
+ var unlocatable = new List();
+ var mismatches = new List();
+ foreach (var field in fields)
+ {
+ var ids = LabelMenuIds(field);
+ EnsureAdapter(field.ObjectHvo, field.Field);
+ if (!AdapterTargets(field))
+ {
+ untargeted.Add(field.Label + " (" + field.Field + ")");
+ continue;
+ }
+ var native = BuildAsTheHostDoes(ids, field);
+ if (!m_view.TryLocateOverrideTarget(field, out _, out _))
+ {
+ unlocatable.Add(string.Format("{0} ({1}) stableId={2} class={3} layout={4}", field.Label,
+ field.Field, field.StableId, field.ClassName, field.LayoutName));
+ var fieldItems = FindItem(native, "Field Visibility").Children
+ .Concat(FindItem(native, "Move Field").Children).ToList();
+ Assert.That(fieldItems.Select(i => i.IsEnabled), Is.All.False,
+ "an unlocatable row disables its field commands: " + field.Label);
+ continue;
+ }
+ compared++;
+ var before = Describe(BuildItemsWithOverrideInterceptor(ids, field));
+ var after = Describe(native);
+ if (!string.Equals(before, after, StringComparison.Ordinal))
+ {
+ mismatches.Add(string.Format("{0} ({1}):{2}--- interceptor path{2}{3}{2}--- authority{2}{4}",
+ field.Label, field.Field, Environment.NewLine, before, after));
+ }
+ }
+ TestContext.WriteLine("Rows the adapter could not target: " + string.Join(", ", untargeted));
+ TestContext.WriteLine("Rows with no override target:" + Environment.NewLine
+ + string.Join(Environment.NewLine, unlocatable));
+ Assert.That(compared, Is.GreaterThan(10), "enough rows have an adapter twin to make the comparison meaningful");
+ Assert.That(mismatches, Is.Empty, string.Join(Environment.NewLine, mismatches));
+ }
+
+ // The adapter finds no slice for the sense's Publish In row and answers Help from another
+ // slice; the authority resolves the row's own topic.
+ [Test]
+ public void Help_OnPublishSenseIn_OpensTheRowsOwnTopic()
+ {
+ UseHelpProvider(KnowsEveryTopic);
+ var sense = m_entry.SensesOS[0];
+ var field = DetailComposer.Compose(m_entry, Cache).Model.Fields
+ .Single(f => f.ObjectHvo == sense.Hvo && f.Field == "PublishIn");
+ EnsureAdapter(field.ObjectHvo, field.Field);
+ TestContext.WriteLine("adapter topic: " + AdapterTree.CurrentSlice?.GetSliceHelpTopicID());
+
+ var topic = m_view.KnownHelpTopic(field);
+
+ Assert.That(topic, Does.Contain("PublishIn"), "the row's help topic names the row's field");
+ var items = BuildItems(new[] { ObjectMenuAuthority.MenuId }, m_view.CreateObjectMenuAuthority(field));
+ Assert.That(FindItem(items, "Help...")?.IsEnabled, Is.True);
+ }
+
+ [Test]
+ public void ObjectMenu_HidesHelp_WhenTheProviderHasNoTopic()
+ {
+ // The fixture's default provider knows no topic; WinForms hides Help for that too.
+ var items = BuildItems(new[] { ObjectMenuAuthority.MenuId }, m_view.CreateObjectMenuAuthority(LexemeFormField()));
+
+ Assert.That(FindItem(items, "Help..."), Is.Null, "no known topic, no Help item");
+ Assert.That(FindItem(items, "Field Visibility"), Is.Not.Null);
+ }
+
+ // A WinForms slice always ends at a topic, the generic one at worst; so does a row that
+ // has no object to generate from.
+ [Test]
+ public void Help_OnARowWithoutAnObject_OffersTheGenericTopic()
+ {
+ UseHelpProvider(new PatternHelpTopicProvider(id => id == FieldHelpTopics.NoHelpTopic));
+ var field = new DetailField("Row", "Row", "Nothing", null, DetailFieldKind.Text,
+ EditorClassification.Known, "Row", null, HostRouting.Inherit, null, null, null);
+ Assert.That(field.ObjectHvo, Is.EqualTo(0), "precondition: no object, no help source");
+
+ Assert.That(m_view.ResolveHelpTopic(field), Is.EqualTo(FieldHelpTopics.NoHelpTopic));
+ var items = BuildItems(new[] { ObjectMenuAuthority.MenuId }, m_view.CreateObjectMenuAuthority(field));
+ Assert.That(FindItem(items, "Help...")?.IsEnabled, Is.True, "the generic topic is still a topic");
+ }
+
+ [Test]
+ public void ObjectMenu_OnARowWithoutOverrideContext_DisablesVisibilityAndMove_AndKeepsHelp()
+ {
+ UseHelpProvider(KnowsEveryTopic);
+ // No class or layout: the row's override target cannot be located.
+ var field = new DetailField("Row", "Row", "Subentries", null, DetailFieldKind.Text,
+ EditorClassification.Known, "Row", null, HostRouting.Inherit, null, null, null,
+ objectHvo: m_entry.Hvo) { HelpTopicId = "khtpTest-Row" };
+
+ var items = BuildItems(new[] { ObjectMenuAuthority.MenuId }, m_view.CreateObjectMenuAuthority(field));
+
+ var visibility = FindItem(items, "Field Visibility");
+ Assert.That(visibility.Children.Select(c => c.IsEnabled), Is.All.False, "nothing to act on: disabled, never guessed");
+ Assert.That(visibility.Children.Select(c => c.Execute), Is.All.Null);
+ Assert.That(FindItem(items, "Move Field").Children.Select(c => c.IsEnabled), Is.All.False);
+ Assert.That(FindItem(items, "Help...")?.IsEnabled, Is.True, "Help needs no override target");
+ }
+
+ [Test]
+ public void FieldVisibility_ThroughTheAuthority_WritesTheOverride()
+ {
+ var field = LexemeFormField();
+ DeleteOverrideFor(field);
+ try
+ {
+ var items = BuildItems(new[] { ObjectMenuAuthority.MenuId }, m_view.CreateObjectMenuAuthority(field));
+ var item = FindItem(items, "Normally hidden");
+ Assert.That(item?.IsEnabled, Is.True);
+
+ item.Execute();
+ DrainMediatorAndIdleQueues();
+
+ var stored = ReadOverrideFor(field);
+ Assert.That(stored, Is.Not.Null, "choosing a visibility writes a project override");
+ var op = stored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.SetVisibility);
+ Assert.That(op.StableId, Is.EqualTo(ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId)));
+ }
+ finally
+ {
+ DeleteOverrideFor(field);
+ }
+ }
+
+ // A sense with no examples composes a ghost Example prompt whose stable id carries the
+ // ghost marker; its field commands act on the Examples sequence node itself.
+ [Test]
+ public void GhostRow_LocatesItsSequenceNode_AndItsVisibilityWritesThatNode()
+ {
+ var sense = m_entry.SensesOS[0];
+ Assert.That(sense.ExamplesOS, Is.Empty, "precondition: the sense composes a ghost Example row");
+ var field = DetailComposer.Compose(m_entry, Cache).Model.Fields.Single(f =>
+ f.ObjectHvo == sense.Hvo && f.Field == "Examples"
+ && f.StableId.EndsWith(DetailField.GhostStableIdSuffix, StringComparison.Ordinal));
+ DeleteOverrideFor(field);
+ var nodeId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId);
+ nodeId = nodeId.Substring(0, nodeId.Length - DetailField.GhostStableIdSuffix.Length);
+
+ Assert.That(m_view.TryLocateOverrideTarget(field, out var templateId, out _), Is.True,
+ "the ghost row locates the node it stands in for");
+ Assert.That(templateId, Is.EqualTo(nodeId));
+
+ try
+ {
+ var items = BuildItems(new[] { ObjectMenuAuthority.MenuId }, m_view.CreateObjectMenuAuthority(field));
+ FindItem(items, "Normally hidden").Execute();
+ DrainMediatorAndIdleQueues();
+
+ var op = ReadOverrideFor(field).Operations.Single(o => o.Kind == ViewOverrideOperationKind.SetVisibility);
+ Assert.That(op.StableId, Is.EqualTo(nodeId), "the override names the sequence node, not the ghost");
+ }
+ finally
+ {
+ DeleteOverrideFor(field);
+ }
+ }
+
+ [Test]
+ public void ObjectMenuAuthority_RejectsALeafItDoesNotAnswer()
+ {
+ var authority = m_view.CreateObjectMenuAuthority(LexemeFormField());
+ var window = m_propertyTable.GetValue("window");
+ var reorderMenu = window.GetContextMenuChoiceGroup(new[] { ReorderVectorMenuAuthority.MenuId });
+ reorderMenu.PopulateNow();
+ var foreignLeaf = reorderMenu.OfType()
+ .First(c => c.HelpId == ReorderVectorMenuAuthority.AlphabeticalOrderCommandId);
+
+ Assert.That(() => authority.Build(ObjectMenuAuthority.MenuId, foreignLeaf),
+ Throws.InvalidOperationException, "an owned id must be answered in full, never partially");
+ }
+
// ----------------------------------------------------------------------------------------
// Delete Sense / Delete object
// ----------------------------------------------------------------------------------------
@@ -1325,9 +1602,8 @@ public void MoveField_OnATopLevelRow_ReordersTheRootList()
+ $"index={location.Index} up={up}");
try
{
- EnsureAdapter(field.ObjectHvo);
- var items = BuildItemsWithOverrideInterceptor(
- new[] { "mnuDataTree-Object" }, field);
+ // No EnsureAdapter: the authority answers Move Field from the row alone.
+ var items = BuildAsTheHostDoes(new[] { ObjectMenuAuthority.MenuId }, field);
var item = FindItem(items, up ? "Move Up" : "Move Down");
Assert.That(item, Is.Not.Null, "the Move Field submenu must offer the item");
Assert.That(item.IsEnabled, Is.True,