diff --git a/Src/Common/Controls/DetailControls/DataTree.cs b/Src/Common/Controls/DetailControls/DataTree.cs index f8923d7cc3..e9524259eb 100644 --- a/Src/Common/Controls/DetailControls/DataTree.cs +++ b/Src/Common/Controls/DetailControls/DataTree.cs @@ -3,6 +3,7 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using SIL.FieldWorks.Common.Controls; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.Framework.DetailControls.Resources; using SIL.FieldWorks.Common.FwUtils; using SIL.FieldWorks.Common.RootSites; @@ -22,7 +23,6 @@ using System.ComponentModel; using System.Diagnostics; using System.Drawing; -using System.Globalization; using System.Linq; using System.Text; using System.Windows.Forms; @@ -4534,8 +4534,8 @@ private bool DisplayJumpToToolAndFilterAnthroItem(UIItemDisplayProperties displa return false; } - var fieldName = XmlUtils.GetOptionalAttributeValue(CurrentSlice.ConfigurationNode, "field"); - if (String.IsNullOrEmpty(fieldName) || !fieldName.Equals("AnthroCodes")) + var fieldName = XmlUtils.GetOptionalAttributeValue(CurrentSlice?.ConfigurationNode, "field"); + if (!AnthroItemFilterLink.Applies(fieldName)) { display.Enabled = display.Visible = false; return true; @@ -4605,7 +4605,7 @@ public virtual bool OnJumpToTool(object commandObject) /// public virtual bool OnJumpToLexiconEditFilterAnthroItems(object commandObject) { - OnJumpToToolAndFilterAnthroItem("FilterAnthroItems", "lexiconEdit"); + OnJumpToToolAndFilterAnthroItem("lexiconEdit"); return true; } @@ -4615,24 +4615,18 @@ public virtual bool OnJumpToLexiconEditFilterAnthroItems(object commandObject) /// public virtual bool OnJumpToNotebookEditFilterAnthroItems(object commandObject) { - OnJumpToToolAndFilterAnthroItem("FilterAnthroItems", "notebookEdit"); + OnJumpToToolAndFilterAnthroItem("notebookEdit"); return true; } - private void OnJumpToToolAndFilterAnthroItem(string linkSetupInfo, string toolToJumpTo) + private void OnJumpToToolAndFilterAnthroItem(string toolToJumpTo) { var obj = ((CurrentSlice.Control as VectorReferenceLauncher).MainControl as VectorReferenceView).SelectedObject; if (obj == null) return; - var hvo = obj.Hvo; - - FwLinkArgs link = new FwAppArgs(Cache.ProjectId.Handle, toolToJumpTo, Guid.Empty); - List additionalProps = link.PropertyTableEntries; - additionalProps.Add(new Property("SuspendLoadListUntilOnChangeFilter", link.ToolName)); - additionalProps.Add(new Property("LinkSetupInfo", linkSetupInfo)); - additionalProps.Add(new Property("HvoOfAnthroItem", hvo.ToString(CultureInfo.InvariantCulture))); #pragma warning disable 618 // suppress obsolete warning - m_mediator.PostMessage("FollowLink", link); + m_mediator.PostMessage("FollowLink", + AnthroItemFilterLink.Create(Cache.ProjectId.Handle, toolToJumpTo, obj.Hvo)); #pragma warning restore 618 } diff --git a/Src/FdoUi/DetailRules/AnthroItemFilterLink.cs b/Src/FdoUi/DetailRules/AnthroItemFilterLink.cs new file mode 100644 index 0000000000..f0b57e100c --- /dev/null +++ b/Src/FdoUi/DetailRules/AnthroItemFilterLink.cs @@ -0,0 +1,43 @@ +// 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 System.Globalization; +using SIL.FieldWorks.Common.FwUtils; + +// Rules both detail views share. Nothing here may depend on WinForms, Avalonia, or a slice +// or control type, so the rules outlive WinForms. DetailRulesBoundaryTests enforces this. +namespace SIL.FieldWorks.Common.DetailRules +{ + /// + /// The jump that opens a tool filtered to the records carrying one anthropology category. + /// It is offered only on the Anthropology Categories field. The link names the tool, holds + /// the tool's list load until the filter is in place, and carries the category. + /// + public static class AnthroItemFilterLink + { + /// The field whose items offer the jump. + public const string FieldName = "AnthroCodes"; + + /// Whether the items of a field offer the jump. + public static bool Applies(string fieldName) + => string.Equals(fieldName, FieldName, StringComparison.Ordinal); + + /// The link for one category. + /// The project the link opens in. + /// The tool to filter, e.g. "lexiconEdit". + /// The category to filter on. + public static FwLinkArgs Create(string projectHandle, string tool, int anthroItemHvo) + { + if (string.IsNullOrEmpty(tool)) + throw new ArgumentException("The tool is required.", nameof(tool)); + var link = new FwAppArgs(projectHandle, tool, Guid.Empty); + var properties = link.PropertyTableEntries; + properties.Add(new Property("SuspendLoadListUntilOnChangeFilter", tool)); + properties.Add(new Property("LinkSetupInfo", "FilterAnthroItems")); + properties.Add(new Property("HvoOfAnthroItem", anthroItemHvo.ToString(CultureInfo.InvariantCulture))); + return link; + } + } +} diff --git a/Src/FdoUi/DetailRules/ComplexFormVisibility.cs b/Src/FdoUi/DetailRules/ComplexFormVisibility.cs new file mode 100644 index 0000000000..2babc4a7f7 --- /dev/null +++ b/Src/FdoUi/DetailRules/ComplexFormVisibility.cs @@ -0,0 +1,92 @@ +// 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.Linq; +using SIL.LCModel; +using SIL.LCModel.Infrastructure; + +// Rules both detail views share. Nothing here may depend on WinForms, Avalonia, or a slice +// or control type, so the rules outlive WinForms. DetailRulesBoundaryTests enforces this. +namespace SIL.FieldWorks.Common.DetailRules +{ + /// + /// The two dictionary-visibility marks a complex form's components can carry: a component + /// is a primary lexeme when the complex form is shown as a subentry under it, and a + /// component is in the show-in list when it shows the complex form as a referenced complex + /// form. Each mark is a subsequence of the complex-form ref's components, kept in + /// component order. + /// + public static class ComplexFormVisibility + { + /// + /// Whether can be marked as showing the subentry of + /// : the ref is a complex form and the component is an + /// entry or a sense. + /// + public static bool CanShowSubentryUnderComponent(ILexEntryRef complexFormRef, ICmObject component) + => complexFormRef != null && complexFormRef.RefType == LexEntryRefTags.krtComplexForm + && (component is ILexEntry || component is ILexSense); + + /// + /// Whether the complex form is shown as a subentry under the component. + /// + public static bool ShowsSubentryUnderComponent(ILexEntryRef complexFormRef, ICmObject component) + => complexFormRef.PrimaryLexemesRS.Contains(component); + + /// + /// Marks the component as showing the subentry, or unmarks it when it already does, as + /// one undoable task. + /// + public static void ToggleSubentryUnderComponent(ILexEntryRef complexFormRef, ICmObject component, + string undoText, string redoText) + => Toggle(complexFormRef.PrimaryLexemesRS, complexFormRef, component, undoText, redoText); + + /// + /// The ref through which is a complex form, or null when + /// it is not one. + /// + public static ILexEntryRef ComplexFormRefOf(ILexEntry complexForm) + => complexForm?.EntryRefsOS.FirstOrDefault(r => r.RefType == LexEntryRefTags.krtComplexForm); + + /// + /// Whether the component shows the complex form as a referenced complex form. + /// + public static bool ShowsComplexFormIn(ILexEntryRef complexFormRef, ICmObject component) + => complexFormRef.ShowComplexFormsInRS.Contains(component); + + /// + /// Marks the component as showing the complex form, or unmarks it when it already does, + /// as one undoable task. + /// + public static void ToggleShowComplexFormIn(ILexEntryRef complexFormRef, ICmObject component, + string undoText, string redoText) + => Toggle(complexFormRef.ShowComplexFormsInRS, complexFormRef, component, undoText, redoText); + + // Removes a marked component; otherwise inserts it where the ref's component order puts + // it. A component the ref does not list is left unmarked. + private static void Toggle(ILcmReferenceSequence marked, ILexEntryRef complexFormRef, + ICmObject component, string undoText, string redoText) + { + UndoableUnitOfWorkHelper.Do(undoText, redoText, complexFormRef.Cache.ActionHandlerAccessor, () => + { + if (marked.Contains(component)) + { + marked.Remove(component); + return; + } + var index = 0; + foreach (var listed in complexFormRef.ComponentLexemesRS) + { + if (listed == component) + { + marked.Insert(index, component); + return; + } + if (marked.Contains(listed)) + index++; + } + }); + } + } +} diff --git a/Src/FdoUi/FdoUiTests/DetailRules/AnthroItemFilterLinkTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/AnthroItemFilterLinkTests.cs new file mode 100644 index 0000000000..ab198c540f --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/AnthroItemFilterLinkTests.cs @@ -0,0 +1,51 @@ +// 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 System.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.DetailRules; +using SIL.FieldWorks.Common.FwUtils; + +namespace SIL.FieldWorks.FdoUi +{ + /// + /// The anthropology-category filter jump: where it applies and what its link carries. + /// + [TestFixture] + public class AnthroItemFilterLinkTests + { + [Test] + public void Applies_OnlyToTheAnthropologyCategoriesField() + { + Assert.That(AnthroItemFilterLink.Applies("AnthroCodes"), Is.True); + Assert.That(AnthroItemFilterLink.Applies("anthrocodes"), Is.False, "field names are exact"); + Assert.That(AnthroItemFilterLink.Applies("SemanticDomains"), Is.False); + Assert.That(AnthroItemFilterLink.Applies(null), Is.False); + } + + [Test] + public void Create_NamesTheTool_HoldsItsListLoad_AndCarriesTheCategory() + { + var link = AnthroItemFilterLink.Create("my-project", "notebookEdit", 4321); + + Assert.That(link, Is.InstanceOf()); + Assert.That(((FwAppArgs)link).Database, Is.EqualTo("my-project")); + Assert.That(link.ToolName, Is.EqualTo("notebookEdit")); + Assert.That(link.TargetGuid, Is.EqualTo(Guid.Empty), "the link filters a list; it targets no record"); + Assert.That(link.PropertyTableEntries.Select(p => p.name + "=" + p.value), Is.EqualTo(new[] + { + "SuspendLoadListUntilOnChangeFilter=notebookEdit", "LinkSetupInfo=FilterAnthroItems", + "HvoOfAnthroItem=4321" + })); + } + + [Test] + public void Create_RequiresATool() + { + Assert.That(() => AnthroItemFilterLink.Create("my-project", null, 1), Throws.ArgumentException); + Assert.That(() => AnthroItemFilterLink.Create("my-project", "", 1), Throws.ArgumentException); + } + } +} diff --git a/Src/FdoUi/FdoUiTests/DetailRules/ComplexFormVisibilityTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/ComplexFormVisibilityTests.cs new file mode 100644 index 0000000000..02ad0956bf --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/ComplexFormVisibilityTests.cs @@ -0,0 +1,151 @@ +// 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.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.DetailRules; +using SIL.LCModel; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.FdoUi +{ + /// + /// The complex-form visibility marks: which components may carry one, and that toggling + /// keeps a mark in component order as one undoable task. + /// + [TestFixture] + public class ComplexFormVisibilityTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry _first; + private ILexSense _secondSense; + private ILexEntry _third; + private ILexEntry _complexForm; + private ILexEntryRef _complexFormRef; + + // A complex form of three components in order: an entry, a sense of another entry, an + // entry. No component is marked. + [SetUp] + public void MakeComplexForm() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var entries = Cache.ServiceLocator.GetInstance(); + _first = entries.Create(); + var second = entries.Create(); + _secondSense = Cache.ServiceLocator.GetInstance().Create(); + second.SensesOS.Add(_secondSense); + _third = entries.Create(); + _complexForm = entries.Create(); + _complexFormRef = Cache.ServiceLocator.GetInstance().Create(); + _complexForm.EntryRefsOS.Add(_complexFormRef); + _complexFormRef.RefType = LexEntryRefTags.krtComplexForm; + _complexFormRef.ComponentLexemesRS.Add(_first); + _complexFormRef.ComponentLexemesRS.Add(_secondSense); + _complexFormRef.ComponentLexemesRS.Add(_third); + }); + } + + [TearDown] + public void DeleteComplexForm() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + foreach (var entry in new[] { _complexForm, _first, _secondSense.Entry, _third }) + { + if (entry.IsValidObject) + entry.Delete(); + } + }); + } + + [Test] + public void CanShowSubentryUnderComponent_NeedsAComplexFormRef_AndAnEntryOrSense() + { + Assert.That(ComplexFormVisibility.CanShowSubentryUnderComponent(_complexFormRef, _first), Is.True); + Assert.That(ComplexFormVisibility.CanShowSubentryUnderComponent(_complexFormRef, _secondSense), Is.True); + Assert.That(ComplexFormVisibility.CanShowSubentryUnderComponent(_complexFormRef, _complexFormRef), Is.False, + "only an entry or a sense can be a primary lexeme"); + Assert.That(ComplexFormVisibility.CanShowSubentryUnderComponent(null, _first), Is.False); + Assert.That(ComplexFormVisibility.CanShowSubentryUnderComponent(MakeVariantRef(), _first), Is.False, + "a variant has no subentry to show"); + } + + // A variant of the first entry, owned by a new entry; the ref type is fixed before the + // component is added, as the model requires. + private ILexEntryRef MakeVariantRef() + { + ILexEntryRef variantRef = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var variant = Cache.ServiceLocator.GetInstance().Create(); + variantRef = Cache.ServiceLocator.GetInstance().Create(); + variant.EntryRefsOS.Add(variantRef); + variantRef.RefType = LexEntryRefTags.krtVariant; + variantRef.ComponentLexemesRS.Add(_first); + }); + return variantRef; + } + + [Test] + public void ToggleSubentryUnderComponent_KeepsThePrimaryLexemesInComponentOrder() + { + Toggle(_secondSense); + Assert.That(_complexFormRef.PrimaryLexemesRS, Is.EqualTo(new ICmObject[] { _secondSense })); + Assert.That(ComplexFormVisibility.ShowsSubentryUnderComponent(_complexFormRef, _secondSense), Is.True); + Assert.That(ComplexFormVisibility.ShowsSubentryUnderComponent(_complexFormRef, _first), Is.False); + + Toggle(_third); + Toggle(_first); + Assert.That(_complexFormRef.PrimaryLexemesRS, Is.EqualTo(new ICmObject[] { _first, _secondSense, _third }), + "a later mark is inserted where the component order puts it"); + + Toggle(_secondSense); + Assert.That(_complexFormRef.PrimaryLexemesRS, Is.EqualTo(new ICmObject[] { _first, _third }), + "toggling a marked component unmarks it"); + } + + [Test] + public void ToggleSubentryUnderComponent_LeavesAnUnlistedComponentUnmarked() + { + Toggle(_complexForm); + Assert.That(_complexFormRef.PrimaryLexemesRS, Is.Empty); + } + + [Test] + public void ToggleSubentryUnderComponent_IsItsOwnUndoableTask() + { + ComplexFormVisibility.ToggleSubentryUnderComponent(_complexFormRef, _first, "Undo mark", "Redo mark"); + + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("Undo mark")); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(_complexFormRef.PrimaryLexemesRS, Is.Empty); + } + + [Test] + public void ComplexFormRefOf_IsTheComplexFormRef_OrNull() + { + Assert.That(ComplexFormVisibility.ComplexFormRefOf(_complexForm), Is.SameAs(_complexFormRef)); + Assert.That(ComplexFormVisibility.ComplexFormRefOf(_first), Is.Null, "a plain entry is no complex form"); + Assert.That(ComplexFormVisibility.ComplexFormRefOf(null), Is.Null); + Assert.That(ComplexFormVisibility.ComplexFormRefOf(MakeVariantRef().OwningEntry), Is.Null, + "a variant ref does not count"); + } + + [Test] + public void ToggleShowComplexFormIn_KeepsTheShowInListInComponentOrder() + { + ComplexFormVisibility.ToggleShowComplexFormIn(_complexFormRef, _third, "Undo", "Redo"); + ComplexFormVisibility.ToggleShowComplexFormIn(_complexFormRef, _first, "Undo", "Redo"); + Assert.That(_complexFormRef.ShowComplexFormsInRS, Is.EqualTo(new ICmObject[] { _first, _third })); + Assert.That(ComplexFormVisibility.ShowsComplexFormIn(_complexFormRef, _third), Is.True); + Assert.That(ComplexFormVisibility.ShowsComplexFormIn(_complexFormRef, _secondSense), Is.False); + + ComplexFormVisibility.ToggleShowComplexFormIn(_complexFormRef, _first, "Undo", "Redo"); + Assert.That(_complexFormRef.ShowComplexFormsInRS.Single(), Is.SameAs(_third)); + } + + private void Toggle(ICmObject component) + => ComplexFormVisibility.ToggleSubentryUnderComponent(_complexFormRef, component, "Undo", "Redo"); + } +} diff --git a/Src/XCore/xCoreInterfaces/Choice.cs b/Src/XCore/xCoreInterfaces/Choice.cs index 9dd9fb4ef8..56cc93011d 100644 --- a/Src/XCore/xCoreInterfaces/Choice.cs +++ b/Src/XCore/xCoreInterfaces/Choice.cs @@ -196,7 +196,10 @@ override public string HelpId } } - private Command CommandObject + /// The command this choice invokes. + /// No command with the configured id is + /// defined. + public Command CommandObject { get { diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.ReferenceVectorMenus.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.ReferenceVectorMenus.cs index 319f27b53d..38ac265b86 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.ReferenceVectorMenus.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.ReferenceVectorMenus.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.FdoUi; using SIL.LCModel; @@ -15,12 +16,13 @@ namespace SIL.FieldWorks.XWorks { /// - /// The reference-vector row menus of the Avalonia detail view: the per-item menu (the - /// reference-choices menu of the clicked item, answered by that item's object UI as a - /// temporary colleague) and the Move Left / Move Right commands, which act on the row's - /// current item through the detail edit context. + /// The reference-vector row menus of the Avalonia detail view: the per-item menu of the + /// clicked item, answered natively by when it owns + /// the item's menu id and otherwise by the item's object UI as a temporary colleague, and + /// the Move Left / Move Right commands, which act on the row's current item through the + /// detail edit context. /// - public partial class RecordEditView + public partial class RecordEditView : IReferenceItemMenuHost { /// /// Shows the per-item menu of a reference-vector row, or for a Ctrl+click runs its @@ -34,34 +36,29 @@ internal void OnDetailItemMenuRequested(DetailMenuRequest request) { if (request.IsDefaultActivation) { - var ui = ResolveItemUi(request); - if (ui == null) - return; - // The item's object UI runs the first enabled jump itself, then disposes. - SyncMenuCommandAdapter(request.Field); - ui.HandleCtrlClick(this); + RunDefaultItemActivation(request); return; } - var items = BuildReferenceItemMenu(request, out var colleague); - if (colleague == null) + var items = BuildReferenceItemMenu(request, out var itemUi); + if (itemUi == null) return; if (items.Count == 0) { - colleague.Dispose(); + itemUi.Dispose(); return; } - // The colleague answers the clicked command, which runs as the menu closes, so - // its disposal is queued behind the close. + // The item's object UI answers the clicked command, which runs as the menu + // closes, so its disposal is queued behind the close. try { m_avaloniaEntryForm.ShowContextMenu(items, request.AnchorControl, request.OpenAtPointer, - () => Avalonia.Threading.Dispatcher.UIThread.Post(colleague.Dispose, + () => Avalonia.Threading.Dispatcher.UIThread.Post(itemUi.Dispose, Avalonia.Threading.DispatcherPriority.Background)); } catch { - colleague.Dispose(); + itemUi.Dispose(); throw; } } @@ -71,24 +68,79 @@ internal void OnDetailItemMenuRequested(DetailMenuRequest request) } } + // A Ctrl+click runs the item menu's first enabled jump: taken from the authority for an + // owned menu, else from the object UI's own Ctrl+click, which needs the adapter. + private void RunDefaultItemActivation(DetailMenuRequest request) + { + var authority = CreateReferenceItemAuthority(request, out var itemUi); + if (itemUi == null) + return; + using (itemUi) + { + if (authority.Owns(itemUi.ContextMenuId)) + { + var window = m_propertyTable.GetValue("window"); + XCoreMenuBridge.CreateMenuItems(window, new[] { itemUi.ContextMenuId }, null, null, authority); + authority.DefaultActivation?.Invoke(); + return; + } + SyncMenuCommandAdapter(request.Field); + itemUi.HandleCtrlClick(this); + } + } + /// - /// Materializes the item menu for the request's selected item: the object UI of that - /// item joins the mediator as a temporary colleague (it answers the Show-in-tool jumps), - /// the first enabled jump is labeled as the Ctrl+click default, and the Move commands are - /// retargeted to the row's current item. Empty, with a null colleague, when the item + /// Materializes the item menu for the request's selected item. A menu id the native + /// authority owns is answered from the row and the item alone, with nothing on the + /// mediator taking part; any other id is built through the item's object UI as a + /// temporary colleague. Empty, with a null , when the item /// cannot be resolved. /// /// The item-menu request; its selected item is the target. - /// The item's object UI, registered on the mediator; the caller - /// disposes it once the menu has closed. + /// The item's object UI, which the menu's commands run through; the + /// caller disposes it once the menu has closed. internal IReadOnlyList BuildReferenceItemMenu(DetailMenuRequest request, - out CmObjectUi colleague) + out CmObjectUi itemUi) { - colleague = null; - var ui = ResolveItemUi(request); + itemUi = null; + var authority = CreateReferenceItemAuthority(request, out var ui); if (ui == null) return Array.Empty(); + IReadOnlyList items; + try + { + if (authority.Owns(ui.ContextMenuId)) + { + var window = m_propertyTable.GetValue("window"); + items = XCoreMenuBridge.CreateMenuItems(window, new[] { ui.ContextMenuId }, null, null, authority); + } + else + { + items = BuildItemMenuThroughTheColleague(request, ui); + } + } + catch + { + ui.Dispose(); // a failed menu must not leave the object UI on the mediator + throw; + } + itemUi = ui; + return items; + } + + /// + /// The item menu built through the mediator: the hidden command adapter is pointed at + /// the row, the item's object UI joins the mediator as a temporary colleague to answer + /// the Show-in-tool jumps, the first enabled jump is labeled as the Ctrl+click default, + /// and the Move commands are retargeted to the row's current item. + /// + /// The item-menu request. + /// The item's object UI; registered on the mediator by this call, and + /// disposed by the caller. + internal IReadOnlyList BuildItemMenuThroughTheColleague(DetailMenuRequest request, + CmObjectUi ui) + { SyncMenuCommandAdapter(request.Field); var registry = new OverrideCommandRegistry(); @@ -110,19 +162,7 @@ internal IReadOnlyList BuildReferenceItemMenu(DetailMenuRequest AddMoveCommands(registry, request); var window = m_propertyTable.GetValue("window"); - IReadOnlyList items; - try - { - items = XCoreMenuBridge.CreateMenuItems(window, new[] { ui.ContextMenuId }, - registry.TryBuild, ui); - } - catch - { - ui.Dispose(); // a failed menu must not leave the colleague on the mediator - throw; - } - colleague = ui; - return items; + return XCoreMenuBridge.CreateMenuItems(window, new[] { ui.ContextMenuId }, registry.TryBuild, ui); } // Points the hidden command adapter at the row, so the row's own slice answers the @@ -140,12 +180,33 @@ private void SyncMenuCommandAdapter(DetailField field) } } + /// + /// The native authority for the item menu of the request's selected item, with the + /// item's object UI it answers the jumps through; both null when the row's object, + /// field, or item cannot be resolved. + /// + /// The item-menu request. + /// The item's object UI, wired to this view's mediator and + /// property table; the caller disposes it. + internal ReferenceItemMenuAuthority CreateReferenceItemAuthority(DetailMenuRequest request, + out CmObjectUi itemUi) + { + itemUi = ResolveItemUi(request, out var rowObject, out var item); + return itemUi == null ? null : new ReferenceItemMenuAuthority(request, itemUi, rowObject, item, this); + } + /// /// The object UI of the request's selected item, wired to this view's mediator and /// property table; null when the row's object, field, or item cannot be resolved. /// internal CmObjectUi ResolveItemUi(DetailMenuRequest request) + => ResolveItemUi(request, out _, out _); + + // As ResolveItemUi, also giving the row's object and the resolved item. + private CmObjectUi ResolveItemUi(DetailMenuRequest request, out ICmObject rowObject, out ICmObject item) { + rowObject = null; + item = null; var field = request?.Field; if (field == null || string.IsNullOrEmpty(request.SelectedItemKey) || !Cache.ServiceLocator.ObjectRepository.TryGetObject(field.ObjectHvo, out var rootObj)) @@ -168,6 +229,8 @@ internal CmObjectUi ResolveItemUi(DetailMenuRequest request) return null; ui.Mediator = m_mediator; ui.PropTable = m_propertyTable; + rowObject = rootObj; + item = Cache.ServiceLocator.ObjectRepository.GetObject(targetHvo); return ui; } @@ -253,5 +316,45 @@ private void CompleteReferenceEdit(Func stage) m_avaloniaEntryForm?.FocusVectorItemOnNextShow(); OnAvaloniaDetailEditCompleted(this, EventArgs.Empty); } + + string IReferenceItemMenuHost.CurrentTool + => m_propertyTable.GetStringProperty("currentContentControl", null); + + void IReferenceItemMenuHost.MoveItem(DetailField field, string key, bool forward) + => MoveReferenceItem(field, key, forward); + + // A jump leaves the record, so pending edits settle first, as every link does. + void IReferenceItemMenuHost.RunJump(Action jump) + { + SettleDetailEdits(); + jump(); + } + + void IReferenceItemMenuHost.FilterByAnthroItem(string tool, int anthroItemHvo) + { + SettleDetailEdits(); +#pragma warning disable 618 // suppress obsolete warning + m_mediator.PostMessage("FollowLink", + AnthroItemFilterLink.Create(Cache.ProjectId.Handle, tool, anthroItemHvo)); +#pragma warning restore 618 + } + + // Model edits of their own: pending edits settle first (a rollback cancels the mark), so + // each is its own undo step; the change re-shows through PropChanged. + void IReferenceItemMenuHost.ToggleSubentryUnderComponent(ILexEntryRef complexFormRef, ICmObject component) + { + if (m_detailEditContext.Settle().Count != 0) + return; + ComplexFormVisibility.ToggleSubentryUnderComponent(complexFormRef, component, + xWorksStrings.ksUndoShowSubentryForComponent, xWorksStrings.ksRedoShowSubentryForComponent); + } + + void IReferenceItemMenuHost.ToggleShowComplexFormIn(ILexEntryRef complexFormRef, ICmObject component) + { + if (m_detailEditContext.Settle().Count != 0) + return; + ComplexFormVisibility.ToggleShowComplexFormIn(complexFormRef, component, + xWorksStrings.ksUndoVisibleComplexForm, xWorksStrings.ksRedoVisibleComplexForm); + } } } diff --git a/Src/xWorks/Avalonia/Hosting/ReferenceItemMenuAuthority.cs b/Src/xWorks/Avalonia/Hosting/ReferenceItemMenuAuthority.cs new file mode 100644 index 0000000000..5654dd738f --- /dev/null +++ b/Src/xWorks/Avalonia/Hosting/ReferenceItemMenuAuthority.cs @@ -0,0 +1,208 @@ +// 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.DetailRules; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.FdoUi; +using SIL.LCModel; +using SIL.Utils; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// What the host does for the per-item menu of a reference-vector row: the commands that + /// change the model or navigate, and the tool the row is shown in. + /// + internal interface IReferenceItemMenuHost + { + /// The current tool, e.g. "lexiconEdit"; null when none is current. + string CurrentTool { get; } + + /// Moves the item with the given key one place within the row. + void MoveItem(DetailField field, string key, bool forward); + + /// + /// Runs a jump that leaves the record, after the record's pending edits settle. + /// + void RunJump(Action jump); + + /// + /// Opens a tool filtered to the records carrying an anthropology category. + /// + void FilterByAnthroItem(string tool, int anthroItemHvo); + + /// + /// Marks or unmarks the component as showing the complex form's subentry. + /// + void ToggleSubentryUnderComponent(ILexEntryRef complexFormRef, ICmObject component); + + /// + /// Marks or unmarks the component as showing the referenced complex form. + /// + void ToggleShowComplexFormIn(ILexEntryRef complexFormRef, ICmObject component); + } + + /// + /// The native authority for the per-item menu of a reference-vector row: the Show-in-tool + /// jumps of the clicked item, the two anthropology-category filter jumps, the two + /// dictionary-visibility marks of a complex form, and Move Left / Move Right. Every answer + /// comes from the row, the clicked item and the item's object UI, which is called directly + /// rather than registered on the mediator, so the menu needs neither the hidden DataTree + /// adapter nor a colleague. + /// + internal sealed class ReferenceItemMenuAuthority : IDetailMenuAuthority + { + internal const string MenuId = "mnuReferenceChoices"; + internal const string ShowSubentryUnderComponentCommandId = "CmdShowSubentryUnderComponent"; + internal const string VisibleComplexFormCommandId = "CmdVisibleComplexForm"; + internal const string FilterLexiconCommandId = "CmdJumpToLexiconEditWithFilter"; + internal const string FilterNotebookCommandId = "CmdJumpToNotebookEditWithFilter"; + internal const string ComponentLexemesField = "ComponentLexemes"; + internal const string ComplexFormEntriesField = "ComplexFormEntries"; + + private readonly DetailMenuRequest _request; + private readonly CmObjectUi _itemUi; + private readonly ICmObject _rowObject; + private readonly ICmObject _item; + private readonly IReferenceItemMenuHost _host; + // The Ctrl+click default's command id, so every build of the menu marks the same jump. + private string _defaultCommandId; + + /// Creates the authority for one item-menu request. + /// The menu request; its field is the row. + /// The object UI of the clicked item within the row's reference, + /// which answers the jumps. + /// The object the row's reference belongs to. + /// The clicked item. + /// The host the commands act through. + public ReferenceItemMenuAuthority(DetailMenuRequest request, CmObjectUi itemUi, ICmObject rowObject, + ICmObject item, IReferenceItemMenuHost host) + { + _request = request ?? throw new ArgumentNullException(nameof(request)); + _itemUi = itemUi ?? throw new ArgumentNullException(nameof(itemUi)); + _rowObject = rowObject ?? throw new ArgumentNullException(nameof(rowObject)); + _item = item ?? throw new ArgumentNullException(nameof(item)); + _host = host ?? throw new ArgumentNullException(nameof(host)); + } + + /// + /// The action of the first enabled jump in menu order, which a Ctrl+click runs; null + /// until the menu has been built, or when no jump is enabled. + /// + public Action DefaultActivation { get; private set; } + + public bool Owns(string menuId) => string.Equals(menuId, MenuId, StringComparison.Ordinal); + + public DetailMenuItem Build(string menuId, ChoiceBase leaf) + { + if (leaf == null) + throw new ArgumentNullException(nameof(leaf)); + var label = XCoreMenuBridge.StripAccelerator(leaf.Label); + var command = leaf as CommandChoice; + switch (leaf.HelpId) + { + case ReorderVectorMenuAuthority.MoveLeftCommandId: + return MoveItem(label, forward: false); + case ReorderVectorMenuAuthority.MoveRightCommandId: + return MoveItem(label, forward: true); + case ShowSubentryUnderComponentCommandId: + return ShowSubentryItem(label, ToolOf(command)); + case VisibleComplexFormCommandId: + return VisibleComplexFormItem(label); + case FilterLexiconCommandId: + case FilterNotebookCommandId: + return FilterItem(label, ToolOf(command)); + default: + if (command != null && string.Equals(command.Message, CmObjectUi.JumpToToolMessage, + StringComparison.Ordinal)) + { + return JumpItem(command); + } + throw new InvalidOperationException(string.Format( + "Menu '{0}' has a leaf '{1}' this authority does not answer.", menuId, leaf.HelpId)); + } + } + + // The tool a command's parameters name, or null. + private static string ToolOf(CommandChoice command) + => command == null ? null : XmlUtils.GetOptionalAttributeValue(command.CommandObject.Parameters[0], "tool"); + + // The item's object UI decides a jump as it would for any menu; the first enabled jump + // is the Ctrl+click default and its label says so. + private DetailMenuItem JumpItem(CommandChoice choice) + { + var command = choice.CommandObject; + var display = new UIItemDisplayProperties(null, choice.Label, true, command.IconName, true); + _itemUi.OnDisplayJumpToTool(command, ref display); + // The display sets the shared command's target; the object UI resolves its own on + // execute, so clear it here rather than leave it for a later menu to pick up. + command.TargetId = Guid.Empty; + if (!display.Visible) + return null; + var label = XCoreMenuBridge.StripAccelerator(display.Text); + if (!display.Enabled) + return new DetailMenuItem(label, isEnabled: false, display.Checked); + Action execute = () => _host.RunJump(() => _itemUi.OnJumpToTool(command)); + if (_defaultCommandId == null || string.Equals(_defaultCommandId, choice.HelpId, StringComparison.Ordinal)) + { + _defaultCommandId = choice.HelpId; + DefaultActivation = execute; + label += CmObjectUi.CtrlClickSuffix; + } + return new DetailMenuItem(label, isEnabled: true, display.Checked, children: null, execute); + } + + // Offered whenever the row reorders, enabled only for a movable current item. + private DetailMenuItem MoveItem(string label, bool forward) + => ReorderVectorMenuAuthority.CanReorderRow(_request.Field) + ? ReorderVectorMenuAuthority.BuildMoveItem(_request, label, forward, _host.MoveItem) + : null; + + // Offered on a complex form's Components row, in the tool the command names, for a + // component that is an entry or a sense. + private DetailMenuItem ShowSubentryItem(string label, string tool) + { + if (!(_rowObject is ILexEntryRef complexFormRef) + || !string.Equals(_request.Field.Field, ComponentLexemesField, StringComparison.Ordinal) + || (tool != null && !string.Equals(tool, _host.CurrentTool, StringComparison.Ordinal)) + || !ComplexFormVisibility.CanShowSubentryUnderComponent(complexFormRef, _item)) + { + return null; + } + var component = _item; + return new DetailMenuItem(label, isEnabled: true, + ComplexFormVisibility.ShowsSubentryUnderComponent(complexFormRef, component), children: null, + () => _host.ToggleSubentryUnderComponent(complexFormRef, component)); + } + + // Offered on an entry's or sense's Complex Forms row for an item that is a complex form. + private DetailMenuItem VisibleComplexFormItem(string label) + { + if (!(_rowObject is ILexEntry || _rowObject is ILexSense) + || !string.Equals(_request.Field.Field, ComplexFormEntriesField, StringComparison.Ordinal)) + { + return null; + } + var complexFormRef = ComplexFormVisibility.ComplexFormRefOf(_item as ILexEntry); + if (complexFormRef == null) + return null; + var component = _rowObject; + return new DetailMenuItem(label, isEnabled: true, + ComplexFormVisibility.ShowsComplexFormIn(complexFormRef, component), children: null, + () => _host.ToggleShowComplexFormIn(complexFormRef, component)); + } + + // Offered on the Anthropology Categories row only; always enabled there. + private DetailMenuItem FilterItem(string label, string tool) + { + if (tool == null || !AnthroItemFilterLink.Applies(_request.Field.Field)) + return null; + var hvo = _item.Hvo; + return new DetailMenuItem(label, isEnabled: true, isChecked: false, children: null, + () => _host.FilterByAnthroItem(tool, hvo)); + } + } +} diff --git a/Src/xWorks/DTMenuHandler.cs b/Src/xWorks/DTMenuHandler.cs index 2b130a4f47..bac167b9fc 100644 --- a/Src/xWorks/DTMenuHandler.cs +++ b/Src/xWorks/DTMenuHandler.cs @@ -12,6 +12,7 @@ using SIL.LCModel.Core.Cellar; using SIL.LCModel.Core.WritingSystems; using SIL.FieldWorks.Common.Controls.FileDialog; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.LCModel.Core.KernelInterfaces; using SIL.FieldWorks.Common.FwUtils; @@ -1443,11 +1444,10 @@ public bool OnDisplayAddComponentToPrimary(object commandObject, ref UIItemDispl if (hvo != 0) { ICmObject target = Cache.ServiceLocator.GetInstance().GetObject(hvo); - if (ler != null && ler.RefType == LexEntryRefTags.krtComplexForm && - (target is ILexEntry || target is ILexSense)) + if (ComplexFormVisibility.CanShowSubentryUnderComponent(ler, target)) { fEnable = true; - fChecked = ler.PrimaryLexemesRS.Contains(target); // LT-11292 + fChecked = ComplexFormVisibility.ShowsSubentryUnderComponent(ler, target); // LT-11292 } } } @@ -1468,40 +1468,8 @@ public bool OnAddComponentToPrimary(object argument) var ler = current.Object as ILexEntryRef; var objForHvo = Cache.ServiceLocator.GetInstance().GetObject(hvo); - if (ler.PrimaryLexemesRS.Contains(objForHvo)) - { // Remove from visibility array - using (UndoableUnitOfWorkHelper helper = new UndoableUnitOfWorkHelper( - Cache.ActionHandlerAccessor, - xWorksStrings.ksUndoShowSubentryForComponent, - xWorksStrings.ksRedoShowSubentryForComponent)) - { - ler.PrimaryLexemesRS.Remove(objForHvo); - helper.RollBack = false; - } - return true; - } - // Otherwise, continue and add it - int idx = 0; - foreach (var obj in ler.ComponentLexemesRS) - { // looping preserves the order of the components - if (obj == objForHvo) - { - using (UndoableUnitOfWorkHelper helper = new UndoableUnitOfWorkHelper( - Cache.ActionHandlerAccessor, - xWorksStrings.ksUndoShowSubentryForComponent, - xWorksStrings.ksRedoShowSubentryForComponent)) - { - ler.PrimaryLexemesRS.Insert(idx, objForHvo); - helper.RollBack = false; - } - break; - } - - if (ler.PrimaryLexemesRS.Contains(obj)) - { - ++idx; - } - } + ComplexFormVisibility.ToggleSubentryUnderComponent(ler, objForHvo, + xWorksStrings.ksUndoShowSubentryForComponent, xWorksStrings.ksRedoShowSubentryForComponent); return true; } @@ -1553,42 +1521,12 @@ public bool OnVisibleComplexForm(object argument) ICmObject le = current.Object; // can be ILexEntry or ILexSense var cplxForm = Cache.ServiceLocator.GetInstance().GetObject(hvo); - ILexEntryRef cfRef; - if (ComponentShowsComplexForm(le, cplxForm, out cfRef)) - { - // Remove from visibility array - using (var helper = new UndoableUnitOfWorkHelper( - Cache.ActionHandlerAccessor, - xWorksStrings.ksUndoVisibleComplexForm, - xWorksStrings.ksRedoVisibleComplexForm)) - { - cfRef.ShowComplexFormsInRS.Remove(le); - helper.RollBack = false; - } + var cfRef = ComplexFormVisibility.ComplexFormRefOf(cplxForm); + Debug.Assert(cfRef != null, "A component of a complex form has no reference to its complex form"); + if (cfRef == null) return true; - } - // Otherwise, continue and add it - int idx = 0; - foreach (var obj in cfRef.ComponentLexemesRS) - { - // looping preserves the order of the components - if (obj == le) - { - using (var helper = new UndoableUnitOfWorkHelper( - Cache.ActionHandlerAccessor, - xWorksStrings.ksUndoVisibleComplexForm, - xWorksStrings.ksRedoVisibleComplexForm)) - { - cfRef.ShowComplexFormsInRS.Insert(idx, le); - helper.RollBack = false; - } - break; - } - if (cfRef.ShowComplexFormsInRS.Contains(obj)) - { - ++idx; - } - } + ComplexFormVisibility.ToggleShowComplexFormIn(cfRef, le, + xWorksStrings.ksUndoVisibleComplexForm, xWorksStrings.ksRedoVisibleComplexForm); return true; } @@ -1603,9 +1541,9 @@ public bool OnVisibleComplexForm(object argument) /// true if cplxForm "contains" component (it has a reference to it). private bool ComponentShowsComplexForm(ICmObject component, ILexEntry cplxForm, out ILexEntryRef cfRef) { - cfRef = (from item in cplxForm.EntryRefsOS where item.RefType == LexEntryRefTags.krtComplexForm select item).FirstOrDefault(); + cfRef = ComplexFormVisibility.ComplexFormRefOf(cplxForm); Debug.Assert(cfRef != null,"A component of a complex form has no reference to its complex form"); - return cfRef.ShowComplexFormsInRS.Contains(component); + return ComplexFormVisibility.ShowsComplexFormIn(cfRef, component); } /// diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs index 5f52eb2905..461a8b7bb5 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs @@ -230,14 +230,12 @@ private DetailField SubentriesField() public void SubentriesItemMenu_OffersTheJumpAsCtrlClickDefault_AndTheMoveCommands_ForTheClickedItem() { MakeTwoSubentries(); - var field = SubentriesField(); - var request = DetailMenuRequest.FromAnchor(null, field, DetailMenuKind.ItemMenu, - new ItemSelection { SelectedItemKey = field.Items[0].Key, SelectedItemIndex = 0 }); + var request = ItemRequest(SubentriesField(), 0); - var items = m_view.BuildReferenceItemMenu(request, out var colleague); + var items = m_view.BuildReferenceItemMenu(request, out var itemUi); try { - Assert.That(colleague, Is.Not.Null, "the clicked item's object UI joins as the temporary colleague"); + Assert.That(itemUi, Is.Not.Null, "the clicked item's object UI answers the jumps"); var jump = items.FirstOrDefault(i => !i.IsSeparator && i.Label.StartsWith("Show Entry in Lexicon", StringComparison.Ordinal)); Assert.That(jump, Is.Not.Null, "the clicked entry's jump command materializes"); @@ -254,8 +252,278 @@ public void SubentriesItemMenu_OffersTheJumpAsCtrlClickDefault_AndTheMoveCommand } finally { - colleague?.Dispose(); + itemUi?.Dispose(); + } + } + + private static DetailMenuRequest ItemRequest(DetailField field, int index) + => DetailMenuRequest.FromAnchor(null, field, DetailMenuKind.ItemMenu, + new ItemSelection { SelectedItemKey = field.Items[index].Key, SelectedItemIndex = index }); + + // Whether the hidden command adapter tree has been built for this view. + private bool HiddenTreeExists => (bool)GetField(m_view, "m_dataTreeInitialized"); + + // The item menu's native authority answers the reference-choices menu, so an item menu + // is built from the row and the item alone. + + [Test] + public void ReferenceItemAuthority_AnswersEveryLeafOfItsMenu() + { + MakeTwoSubentries(); + var authority = m_view.CreateReferenceItemAuthority(ItemRequest(SubentriesField(), 0), out var itemUi); + using (itemUi) + { + var window = m_propertyTable.GetValue("window"); + var menu = window.GetContextMenuChoiceGroup(new[] { ReferenceItemMenuAuthority.MenuId }); + menu.PopulateNow(querySubmenuVisibility: false); + + var leaves = Leaves(menu).ToList(); + Assert.That(leaves.Count, Is.GreaterThanOrEqualTo(40), + "the shipped menu carries the jumps, the filters, the two marks and the moves"); + foreach (var leaf in leaves) + { + Assert.That(leaf.ConfigurationNode, Is.Not.Null, + "leaf '{0}' has no configuration node, so no authority can claim it", leaf.Label); + Assert.That(() => authority.Build(ReferenceItemMenuAuthority.MenuId, leaf), Throws.Nothing, + "the authority does not answer leaf '{0}'", leaf.HelpId); + } + Assert.That(() => XCoreMenuBridge.CreateMenuItems(window, new[] { ReferenceItemMenuAuthority.MenuId }, + null, null, authority), Throws.Nothing, "the whole menu builds through the bridge"); + var targets = leaves.OfType() + .Where(c => c.Message == SIL.FieldWorks.FdoUi.CmObjectUi.JumpToToolMessage) + .Select(c => c.CommandObject.TargetId).Distinct().ToList(); + Assert.That(targets, Is.EqualTo(new[] { Guid.Empty }), + "building the menu leaves no jump command carrying a target for a later menu"); + } + } + + [Test] + public void ReferenceItemAuthority_RejectsALeafItDoesNotAnswer() + { + MakeTwoSubentries(); + var authority = m_view.CreateReferenceItemAuthority(ItemRequest(SubentriesField(), 0), out var itemUi); + using (itemUi) + { + var window = m_propertyTable.GetValue("window"); + var objectMenu = window.GetContextMenuChoiceGroup(new[] { RecordEditView.ObjectMenuId }); + objectMenu.PopulateNow(); + var foreignLeaf = objectMenu.OfType().First(c => c.HelpId == ObjectMenuAuthority.HelpCommandId); + + Assert.That(() => authority.Build(ReferenceItemMenuAuthority.MenuId, foreignLeaf), + Throws.InvalidOperationException, "an owned id must be answered in full, never partially"); + } + } + + [Test] + public void ItemMenu_IsOwned_ForReferenceChoices_ButNotForEnvironments() + { + MakeTwoSubentries(); + var authority = m_view.CreateReferenceItemAuthority(ItemRequest(SubentriesField(), 0), out var itemUi); + using (itemUi) + { + Assert.That(authority.Owns(itemUi.ContextMenuId), Is.True, "a subentry's item menu is answered natively"); + Assert.That(authority.Owns(RecordEditView.ObjectMenuId), Is.False); + } + var envAuthority = m_view.CreateReferenceItemAuthority(ItemRequest(EnvironmentsFieldWithOneItem(), 0), + out var envUi); + using (envUi) + { + Assert.That(envAuthority.Owns(envUi.ContextMenuId), Is.False, + "the environments menu still takes the colleague path"); + } + } + + [Test] + public void ReferenceItemMenu_OfASubentry_IsBuiltWithoutTheAdapterOrTheMediator() + { + MakeTwoSubentries(); + var request = ItemRequest(SubentriesField(), 0); + Assert.That(HiddenTreeExists, Is.False, "precondition: no hidden tree exists yet"); + var window = m_propertyTable.GetValue("window"); + var spy = new DisplaySpyColleague(); + window.Mediator.AddColleague(spy); + try + { + var items = m_view.BuildReferenceItemMenu(request, out var itemUi); + using (itemUi) + { + Assert.That(spy.Asked, Is.False, "no leaf of an owned item menu reaches the mediator"); + Assert.That(HiddenTreeExists, Is.False, "an owned item menu never builds the hidden tree"); + var jump = FindItem(items, "Show Entry in Lexicon" + SIL.FieldWorks.FdoUi.CmObjectUi.CtrlClickSuffix); + Assert.That(jump?.Execute, Is.Not.Null, "the item's object UI answers the jump directly"); + Assert.That(FindItem(items, "Move Right")?.IsEnabled, Is.True); + } + } + finally + { + window.Mediator.RemoveColleague(spy); + } + } + + // Baseline = the path this replaces: the item's object UI as a temporary colleague, the + // adapter pointed at the row, the jump marker and the moves on the interceptor. + [Test] + public void ReferenceItemMenu_NativeAuthority_RendersWhatTheColleaguePathRendered_ForEveryItem() + { + MakeTwoSubentries(); + AddAnthropologyCategoryToSense(); + var fields = DetailComposer.Compose(m_entry, Cache).Model.Fields + .Where(f => f.Kind == DetailFieldKind.ReferenceVector && f.Items.Count > 0).ToList(); + + var compared = 0; + var mismatches = new List(); + var leaks = new List(); + foreach (var field in fields) + { + for (var index = 0; index < field.Items.Count; index++) + { + var request = ItemRequest(field, index); + var ui = m_view.ResolveItemUi(request); + if (ui == null) + continue; + string before; + using (ui) + { + if (!string.Equals(ui.ContextMenuId, ReferenceItemMenuAuthority.MenuId, StringComparison.Ordinal)) + continue; + before = Describe(WithoutLeakedSubentryMark( + m_view.BuildItemMenuThroughTheColleague(request, ui), field, leaks)); + } + var native = m_view.BuildReferenceItemMenu(request, out var itemUi); + itemUi.Dispose(); + compared++; + var after = Describe(native); + if (!string.Equals(before, after, StringComparison.Ordinal)) + { + var slice = AdapterTree?.CurrentSlice; + var target = slice?.Object == null ? "none" : string.Format("{0} flid {1} selection {2}", + slice.Object.ClassName, slice.Flid, slice.Object.Hvo); + mismatches.Add(string.Format("{0} ({1}) item {2} [adapter slice: {6}]:{3}--- colleague path{3}{4}{3}--- authority{3}{5}", + field.Label, field.Field, index, Environment.NewLine, before, after, target)); + } + } + } + Assert.That(leaks, Is.EqualTo(new[] { "Publish Sense In" }), + "the baseline drops the adapter's leak on exactly one row"); + Assert.That(compared, Is.GreaterThanOrEqualTo(3), "subentries and a category are compared"); + Assert.That(mismatches, Is.Empty, string.Join(Environment.NewLine, mismatches)); + } + + // The adapter leaks this mark as enabled when it lands on a field-less slice (its + // display handler returns before setting state); the native path hides it off a + // Components row. + private static IReadOnlyList WithoutLeakedSubentryMark(IReadOnlyList items, + DetailField field, List leaks) + { + if (string.Equals(field.Field, ReferenceItemMenuAuthority.ComponentLexemesField, StringComparison.Ordinal)) + return items; + var kept = items.Where(i => i.IsSeparator + || !string.Equals(i.Label, "Show Subentry under this Component", StringComparison.Ordinal)).ToList(); + if (kept.Count == items.Count) + return items; + leaks.Add(field.Label); + while (kept.Count > 0 && kept[kept.Count - 1].IsSeparator) + kept.RemoveAt(kept.Count - 1); + return kept; + } + + // The one divergence from the colleague path, by design: the mark reads the clicked + // item, where the hidden tree has no selection and so never offers it. + [Test] + public void ShowSubentryUnderComponent_OnAComplexFormsComponentsRow_TogglesThePrimaryLexeme() + { + MakeTwoSubentries(); + var complexForm = m_entry.ComplexFormEntries.First(); + var complexFormRef = complexForm.EntryRefsOS.Single(); + var field = DetailComposer.Compose(complexForm, Cache).Model.Fields.Single(f => + f.Field == ReferenceItemMenuAuthority.ComponentLexemesField && f.ObjectHvo == complexFormRef.Hvo); + var request = ItemRequest(field, field.Items.ToList().FindIndex(i => i.Key == m_entry.Guid.ToString())); + const string label = "Show Subentry under this Component"; + + var items = m_view.BuildReferenceItemMenu(request, out var itemUi); + using (itemUi) + { + var show = FindItem(items, label); + Assert.That(show, Is.Not.Null, "the mark is offered for a component of a complex form"); + Assert.That(show.IsChecked, Is.True, "the component is a primary lexeme"); + show.Execute(); + DrainMediatorAndIdleQueues(); + Assert.That(complexFormRef.PrimaryLexemesRS, Does.Not.Contain(m_entry)); } + items = m_view.BuildReferenceItemMenu(request, out itemUi); + using (itemUi) + { + var show = FindItem(items, label); + Assert.That(show.IsChecked, Is.False); + show.Execute(); + DrainMediatorAndIdleQueues(); + Assert.That(complexFormRef.PrimaryLexemesRS, Is.EqualTo(new ICmObject[] { m_entry })); + } + + // The colleague path needs the hidden tree to show the complex form, as it would when + // its Components row is on screen. + m_view.Clerk.JumpToRecord(complexForm.Hvo); + DrainMediatorAndIdleQueues(); + var colleague = m_view.ResolveItemUi(request); + using (colleague) + { + Assert.That(FindItem(m_view.BuildItemMenuThroughTheColleague(request, colleague), label), Is.Null, + "the colleague path reads a selection the hidden tree does not have"); + } + } + + [Test] + public void AnthropologyCategoryItem_OffersTheFilterJumps_AndItsListJumpAsTheDefault() + { + var sense = AddAnthropologyCategoryToSense(); + var field = DetailComposer.Compose(m_entry, Cache).Model.Fields.Single(f => + f.Field == AnthroItemFilterLink.FieldName && f.ObjectHvo == sense.Hvo); + + var category = sense.AnthroCodesRC.First(); + var items = m_view.BuildReferenceItemMenu(ItemRequest(field, 0), out var itemUi); + using (itemUi) + { + var lexicon = FindItem(items, "Filter for Lexical Entries with this category"); + var notebook = FindItem(items, "Filter for Notebook Records with this category"); + Assert.That(lexicon?.Execute, Is.Not.Null, "the lexicon filter jump is offered and runs"); + Assert.That(notebook?.Execute, Is.Not.Null, "the notebook filter jump is offered and runs"); + var link = CaptureFollowLink(lexicon.Execute); + Assert.That(link, Is.Not.Null, "the filter jump posts a link"); + Assert.That(link.ToolName, Is.EqualTo("lexiconEdit")); + Assert.That(link.PropertyTableEntries.Single(p => p.name == "HvoOfAnthroItem").value, + Is.EqualTo(category.Hvo.ToString()), "the link filters on the clicked category"); + var listJump = items.FirstOrDefault(i => !i.IsSeparator + && i.Label.StartsWith("Show in ", StringComparison.Ordinal)); + Assert.That(listJump?.Label, Does.EndWith(SIL.FieldWorks.FdoUi.CmObjectUi.CtrlClickSuffix), + "the category's own list jump is the Ctrl+click default"); + Assert.That(FindItem(items, "Move Right"), Is.Null, "a category collection has no order to move in"); + } + + MakeTwoSubentries(); + items = m_view.BuildReferenceItemMenu(ItemRequest(SubentriesField(), 0), out itemUi); + using (itemUi) + { + Assert.That(FindItem(items, "Filter for Lexical Entries with this category"), Is.Null, + "the filter jumps belong to the Anthropology Categories row alone"); + } + } + + // The first sense gains one anthropology category, so the entry composes an Anthropology + // Categories row with one item. + private ILexSense AddAnthropologyCategoryToSense() + { + var sense = m_entry.SensesOS[0]; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + if (Cache.LangProject.AnthroListOA == null) + Cache.LangProject.AnthroListOA = Cache.ServiceLocator.GetInstance().Create(); + var category = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.AnthroListOA.PossibilitiesOS.Add(category); + category.Name.SetAnalysisDefaultWritingSystem("Kinship"); + sense.AnthroCodesRC.Add(category); + }); + DrainMediatorAndIdleQueues(); + return sense; } // An allomorph carrying one environment: PhoneEnv is a reference COLLECTION, which @@ -294,9 +562,7 @@ private DetailField EnvironmentsFieldWithOneItem() [Test] public void AnEnvironmentItem_ResolvesToTheMenuThatCarriesItsCommands() { - var field = EnvironmentsFieldWithOneItem(); - var request = DetailMenuRequest.FromAnchor(null, field, DetailMenuKind.ItemMenu, - new ItemSelection { SelectedItemKey = field.Items[0].Key, SelectedItemIndex = 0 }); + var request = ItemRequest(EnvironmentsFieldWithOneItem(), 0); using (var ui = m_view.ResolveItemUi(request)) { @@ -424,10 +690,47 @@ public void SubentriesCtrlClick_ResolvesTheClickedEntry_AndRunsTheDefaultJumpPat } Assert.That(Cache.ServiceLocator.ObjectRepository.IsValidObjectId(targetHvo), Is.True); - // The default activation runs the object UI's Ctrl-click path end to end without - // faulting (the jump is a mediator FollowLink this headless window does not service). - Assert.DoesNotThrow(() => m_view.OnDetailItemMenuRequested(request)); - DrainMediatorAndIdleQueues(); + // The default activation posts the first enabled jump's link for the clicked + // subentry and builds no hidden tree on the way. + var link = CaptureFollowLink(() => m_view.OnDetailItemMenuRequested(request)); + Assert.That(link, Is.Not.Null, "the Ctrl+click default runs a jump"); + Assert.That(link.ToolName, Is.EqualTo("lexiconEdit"), "Show Entry in Lexicon is the first enabled jump"); + Assert.That(link.TargetGuid, Is.EqualTo(new Guid(field.Items[1].Key)), "the jump targets the clicked subentry"); + Assert.That(HiddenTreeExists, Is.False, "a native Ctrl+click never points the adapter at the row"); + } + + // Runs an action and returns the link it posts, once the post has been delivered. + private FwLinkArgs CaptureFollowLink(Action action) + { + var window = m_propertyTable.GetValue("window"); + var spy = new FollowLinkSpy(); + window.Mediator.AddColleague(spy); + try + { + action(); + DrainMediatorAndIdleQueues(); + return spy.Link; + } + finally + { + window.Mediator.RemoveColleague(spy); + } + } + + // Captures the link a jump posts. It sorts ahead of the window's own link listener, + // which shares the High priority and was registered first, so it sees the link first. + private sealed class FollowLinkSpy : IxCoreColleague + { + public FwLinkArgs Link { get; private set; } + public void Init(Mediator mediator, PropertyTable propertyTable, XmlNode configurationParameters) { } + public IxCoreColleague[] GetMessageTargets() => new IxCoreColleague[] { this }; + public bool ShouldNotCall => false; + public int Priority => (int)ColleaguePriority.High - 1; + public bool OnFollowLink(object args) + { + Link = args as FwLinkArgs; + return true; + } } [Test] @@ -797,7 +1100,8 @@ public DetailMenuItem Build(string menuId, ChoiceBase leaf) : new DetailMenuItem(XCoreMenuBridge.StripAccelerator(leaf.Label), isEnabled: true); } - // Records whether the mediator asked anyone to display the Always-visible command. + // Records whether the mediator asked anyone to display the Always-visible command or a + // Show-in-tool jump. private sealed class DisplaySpyColleague : IxCoreColleague { public bool Asked { get; private set; } @@ -810,6 +1114,11 @@ public bool OnDisplayShowFieldAlwaysVisible(object commandObject, ref UIItemDisp Asked = true; return false; } + public bool OnDisplayJumpToTool(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } } private IReadOnlyList BuildWithSpy(string[] ids, IDetailMenuAuthority authority,