From 0d347585d8e5090850e97c6dfec5ccc0491efc87 Mon Sep 17 00:00:00 2001 From: mark-sil <83427558+mark-sil@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:04:06 -0400 Subject: [PATCH 1/2] LT-22691: Answer the allomorph, variant and relation menus natively The Lexicon detail view now answers the item menus of allomorphs and affix processes, a variant's form row and the lexical-relation rows without the hidden DataTree. Until now their Merge, Delete Reference, Add and Replace Reference, Edit Reference Set Details, Swap and Convert leaves were built by the WinForms adapter. After such a command the view now scrolls to the row it puts the keyboard on. Until now the re-show restored the old scroll position, so the focused row could sit off-screen. The rules both views need live in Src/FdoUi/DetailRules and the WinForms slices and launchers call them; the strings they share move to FdoUiStrings. The menu ids this changes: * mnuDataTree-Allomorph * mnuDataTree-AffixProcess * mnuDataTree-VariantForm * mnuDataTree-DeleteAddLexReference * mnuDataTree-DeleteReplaceLexReference * mnuDataTree-AlternateForm (removed with its four commands: no row in either view shows it) Co-Authored-By: Claude Opus 5.5 --- .../Configuration/Lexicon/DataTreeInclude.xml | 19 - .../Configuration/Parts/LexEntryParts.xml | 2 +- .../DetailControlsStrings.Designer.cs | 18 - .../Resources/DetailControlsStrings.resx | 6 - Src/Common/Controls/DetailControls/Slice.cs | 100 +---- .../FwAvalonia/Detail/DetailFocusMemory.cs | 17 +- .../FwAvaloniaTests/DetailFocusMemoryTests.cs | 44 ++ Src/FdoUi/DetailRules/DetailObjectMerge.cs | 69 +++ .../DetailRules/LexicalRelationEditing.cs | 409 ++++++++++++++++++ .../DetailRules/VariantBackReferences.cs | 73 ++++ Src/FdoUi/FdoUiCore.cs | 71 ++- Src/FdoUi/FdoUiStrings.Designer.cs | 180 ++++++++ Src/FdoUi/FdoUiStrings.resx | 63 +++ .../DetailRules/DetailObjectMergeTests.cs | 115 +++++ .../LexicalRelationEditingTests.cs | 245 +++++++++++ .../DetailRules/VariantBackReferencesTests.cs | 108 +++++ Src/LexText/Lexicon/LexEdStrings.Designer.cs | 162 ------- Src/LexText/Lexicon/LexEdStrings.resx | 57 --- .../Lexicon/LexReferenceCollectionLauncher.cs | 38 +- .../Lexicon/LexReferenceCollectionSlice.cs | 2 +- Src/LexText/Lexicon/LexReferenceMultiSlice.cs | 170 +------- .../Lexicon/LexReferencePairLauncher.cs | 65 +-- Src/LexText/Lexicon/LexReferencePairSlice.cs | 2 +- .../Lexicon/LexReferenceSequenceLauncher.cs | 35 +- .../Lexicon/LexReferenceSequenceSlice.cs | 2 +- .../LexReferenceTreeBranchesLauncher.cs | 37 +- .../Lexicon/LexReferenceTreeBranchesSlice.cs | 2 +- .../Lexicon/LexReferenceTreeRootLauncher.cs | 100 +---- .../Lexicon/LexReferenceTreeRootSlice.cs | 2 +- .../LexReferenceUnidirectionalLauncher.cs | 37 +- .../LexReferenceUnidirectionalSlice.cs | 2 +- .../Lexicon/RecordReferenceVectorLauncher.cs | 5 +- .../Avalonia/Composer/DetailComposer.cs | 22 +- .../Hosting/RecordEditView.LexiconMenus.cs | 408 ++++++++++++++++- .../Hosting/LexiconMenuAuthorityTests.cs | 171 +++++++- .../Hosting/LexiconMenuExecutionTests.cs | 311 ++++++++++++- 36 files changed, 2377 insertions(+), 792 deletions(-) create mode 100644 Src/FdoUi/DetailRules/DetailObjectMerge.cs create mode 100644 Src/FdoUi/DetailRules/LexicalRelationEditing.cs create mode 100644 Src/FdoUi/DetailRules/VariantBackReferences.cs create mode 100644 Src/FdoUi/FdoUiTests/DetailRules/DetailObjectMergeTests.cs create mode 100644 Src/FdoUi/FdoUiTests/DetailRules/LexicalRelationEditingTests.cs create mode 100644 Src/FdoUi/FdoUiTests/DetailRules/VariantBackReferencesTests.cs diff --git a/DistFiles/Language Explorer/Configuration/Lexicon/DataTreeInclude.xml b/DistFiles/Language Explorer/Configuration/Lexicon/DataTreeInclude.xml index 76ea700dd7..ea2679df0f 100644 --- a/DistFiles/Language Explorer/Configuration/Lexicon/DataTreeInclude.xml +++ b/DistFiles/Language Explorer/Configuration/Lexicon/DataTreeInclude.xml @@ -56,21 +56,9 @@ - - - - - - - - - - - - @@ -335,13 +323,6 @@ - - - - - - - diff --git a/DistFiles/Language Explorer/Configuration/Parts/LexEntryParts.xml b/DistFiles/Language Explorer/Configuration/Parts/LexEntryParts.xml index 18bdb91d75..d98493ebc0 100644 --- a/DistFiles/Language Explorer/Configuration/Parts/LexEntryParts.xml +++ b/DistFiles/Language Explorer/Configuration/Parts/LexEntryParts.xml @@ -116,7 +116,7 @@ header="true" helpTopicID="khtpField-LexEntry-AlternateFormsSection"/> - + diff --git a/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.Designer.cs b/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.Designer.cs index 8f9f66a45e..cba0719878 100644 --- a/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.Designer.cs +++ b/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.Designer.cs @@ -367,15 +367,6 @@ internal static string ksRedoDeleteItem { } } - /// - /// Looks up a localized string similar to Redo Delete Reference. - /// - internal static string ksRedoDeleteRef { - get { - return ResourceManager.GetString("ksRedoDeleteRef", resourceCulture); - } - } - /// /// Looks up a localized string similar to Redo Demote. /// @@ -583,15 +574,6 @@ internal static string ksUndoDeleteItem { } } - /// - /// Looks up a localized string similar to Undo Delete Reference. - /// - internal static string ksUndoDeleteRef { - get { - return ResourceManager.GetString("ksUndoDeleteRef", resourceCulture); - } - } - /// /// Looks up a localized string similar to Undo Demote. /// diff --git a/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.resx b/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.resx index e992d0e1fa..9466b89661 100644 --- a/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.resx +++ b/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.resx @@ -230,12 +230,6 @@ An entry that has components cannot have a morph type of root, since by definition a root is indivisible. Would you like Language Explorer to delete the references to components in this entry? - - Redo Delete Reference - - - Undo Delete Reference - Redo Update Picture diff --git a/Src/Common/Controls/DetailControls/Slice.cs b/Src/Common/Controls/DetailControls/Slice.cs index 94415c7f8b..325e8ba987 100644 --- a/Src/Common/Controls/DetailControls/Slice.cs +++ b/Src/Common/Controls/DetailControls/Slice.cs @@ -2320,24 +2320,9 @@ public virtual bool CanDeleteReferenceNow(Command cmd) public virtual void HandleDeleteReferenceCommand(Command cmd) { CheckDisposed(); - if (NextSlice != null && NextSlice.Object != null) + if (NextSlice?.Object is ILexEntryRef ler && ContainingDataTree.Root is ILexEntry root) { - var ler = NextSlice.Object as ILexEntryRef; - if (ler != null) - { - UndoableUnitOfWorkHelper.Do(DetailControlsStrings.ksUndoDeleteRef, DetailControlsStrings.ksRedoDeleteRef, ler, () => - { - ler.ComponentLexemesRS.Remove(ContainingDataTree.Root); - // probably not needed, but safe... - if (ler.PrimaryLexemesRS.Contains(ContainingDataTree.Root)) - ler.PrimaryLexemesRS.Remove(ContainingDataTree.Root); - - ILexEntry entry; - entry = (ILexEntry)m_cache.ServiceLocator.GetObject(ler.OwningEntry.Hvo); - if (entry.EntryRefsOS.Contains(ler)) - entry.EntryRefsOS.Remove(ler); - }); - } + VariantBackReferences.DeleteReference(root, ler); } } @@ -2385,38 +2370,9 @@ public virtual int Flid } } - private bool FromVariantBackRefField - { - get - { - var rootObj = ContainingDataTree.Root; - int clidRoot = rootObj.ClassID; - return clidRoot == LexEntryTags.kClassId && - Object != null && Object != rootObj && - Object.Owner != null && Object.Owner != rootObj && - (Object.ClassID == clidRoot || Object.Owner.ClassID == clidRoot); - } - } + private bool FromVariantBackRefField => BackRefObject != null; - private ICmObject BackRefObject - { - get - { - var rootObj = ContainingDataTree.Root; - int clidRoot = rootObj.ClassID; - if (clidRoot == LexEntryTags.kClassId && - Object != null && Object != rootObj && - Object.Owner != null && Object.Owner != rootObj) - { - if (Object.ClassID == clidRoot) - return Object; - - if (Object.Owner.ClassID == clidRoot) - return Object.Owner; - } - return null; - } - } + private ICmObject BackRefObject => VariantBackReferences.BackReferencedEntry(ContainingDataTree.Root, Object); /// /// is it possible to do a deletion menu command on this slice right now? @@ -2435,53 +2391,7 @@ public bool GetCanMergeNow() { CheckDisposed(); - var obj = GetObjectForMenusToOperateOn(); - if (obj == null) - return false; - - var owner = obj.Owner; - int flid = obj.OwningFlid; - // No support yet for atomic properties. - if (!Cache.IsVectorProperty(flid)) - return false; - - // Special handling for allomorphs, as they can be merged into the lexeme form. - int clsid = obj.ClassID; - if (flid == LexEntryTags.kflidAlternateForms) - { - // We can merge an alternate with the lexeme form, - // if it is the same class. - if (clsid == ((ILexEntry) owner).LexemeFormOA.ClassID) - return true; - } - // A subsense can always merge into its owning sense. - if (flid == LexSenseTags.kflidSenses) - return true; - - int vectorSize = Cache.DomainDataByFlid.get_VecSize(owner.Hvo, flid); - if (owner.IsFieldRequired(flid) - && vectorSize < 2) - return false; - - // Check now to see if there are any other objects of the same class in the flid, - // since only objects of the same class can be merged. - int[] contents; - int chvoMax = Cache.DomainDataByFlid.get_VecSize(owner.Hvo, flid); - using (ArrayPtr arrayPtr = MarshalEx.ArrayToNative(chvoMax)) - { - Cache.DomainDataByFlid.VecProp(owner.Hvo, flid, chvoMax, out chvoMax, arrayPtr); - contents = MarshalEx.NativeToArray(arrayPtr, chvoMax); - } - foreach (int hvoInner in contents) - { - var innerObj = m_cache.ServiceLocator.GetInstance().GetObject(hvoInner); - if (innerObj != obj && clsid == innerObj.ClassID) - { - return true; - } - } - - return false; + return DetailObjectMerge.CanMergeNow(GetObjectForMenusToOperateOn()); } /// diff --git a/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs b/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs index b6b0011ee1..49bf3b66ec 100644 --- a/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs +++ b/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs @@ -231,7 +231,8 @@ public static bool TryRestoreScroll(Control root, Memento memento) /// current item when the memento asks for it (a move or removal made from the row keeps /// the keyboard in the row); else the first surviving row the outgoing view asked for /// (a gesture removed the focused row), which comes before the remembered editor only - /// when that is a row's menu button. Safe to call before the view is attached. + /// when that is a row's menu button. The scroll offset yields to the row of the object + /// asked for. Safe to call before the view is attached. /// public static void RestoreAfterLayout(Control root, Memento memento) { @@ -263,12 +264,18 @@ public static void RestoreAfterLayout(Control root, Memento memento) else RestoreRequestedFocus(root, memento); } - // A bring-into-view request queued by a focus change is applied by the next - // arrange; the remembered offset is re-asserted after it. + // After the next arrange: re-assert the remembered offset, or bring the row + // of the object the gesture chose into view wherever it sits. if (needsScroll) { - Avalonia.Threading.Dispatcher.UIThread.Post(() => TryRestoreScroll(root, memento), - Avalonia.Threading.DispatcherPriority.Background); + var showsRequestedObject = focused && memento.ObjectHvo != 0; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + if (showsRequestedObject) + (TopLevel.GetTopLevel(root)?.FocusManager?.GetFocusedElement() as Control)?.BringIntoView(); + else + TryRestoreScroll(root, memento); + }, Avalonia.Threading.DispatcherPriority.Background); } }; root.LayoutUpdated += onLayout; diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailFocusMemoryTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailFocusMemoryTests.cs index 0a39a13029..149a50822c 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailFocusMemoryTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailFocusMemoryTests.cs @@ -135,6 +135,35 @@ public void RequestedObjectFocus_FocusesTheFirstRowShowingTheObject() "the new form's editor takes focus"); } + // A gesture replaced an object whose row sits far from where the user had scrolled (a + // merge into the lexeme form from an allomorph near the bottom): the focused row shows + // instead of the remembered offset. + [AvaloniaTest] + public void RequestedObjectFocus_ScrollsToTheObjectsRow_InsteadOfKeepingTheOffset() + { + var first = NewLongViewOfForm(8); + var window = new Window { Content = first, Width = 420, Height = 200 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + FindScroller(first).Offset = new Avalonia.Vector(0, 400); + Dispatcher.UIThread.RunJobs(); + Assert.That(FindScroller(first).Offset.Y, Is.EqualTo(400).Within(0.5), "precondition: scrolled well down"); + + first.RequestObjectFocusOnRebuild(9); + var memento = DetailFocusMemory.Capture(first); + var second = NewLongViewOfForm(9); + DetailFocusMemory.RestoreAfterLayout(second, memento); + window.Content = second; + Dispatcher.UIThread.RunJobs(); + second.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(FindEditor(second, "LexEntry/identity/#0@9.vern").IsFocused, Is.True, "the new form's editor takes focus"); + // The editor, not the row, is brought into view: a few pixels of row padding stay + // above. + Assert.That(FindScroller(second).Offset.Y, Is.LessThan(10), "the view scrolled up to the editor's row"); + } + // A gesture made from a header's hotlinks link added an object: the link survives the // re-show and is remembered, but the object asked for takes focus over it. [AvaloniaTest] @@ -228,6 +257,21 @@ private static DataTree NewViewOfForm(int formHvo, bool withHotlinks = false) menuRequested: withHotlinks ? request => { } : (Action)null); } + // A long view whose first row shows the form with the given hvo; the rest show object 3. + private static DataTree NewLongViewOfForm(int formHvo) + { + var values = new List { new DetailWsValue("vern", "casa") }; + var fields = new List(); + fields.Add(new DetailField("LexEntry/identity/#0@" + formHvo, "Lexeme Form", "Form", "vernacular", DetailFieldKind.Text, + EditorClassification.Known, null, null, HostRouting.Product, values, null, null, objectHvo: formHvo)); + for (var i = 1; i < 60; i++) + { + fields.Add(new DetailField("LexEntry/identity/#" + i + "@3", "Field " + i, "Gloss", "analysis", DetailFieldKind.Text, + EditorClassification.Known, null, null, HostRouting.Product, values, null, null, objectHvo: 3)); + } + return new DataTree(new DetailModel("LexEntry", "identity", fields, new List())); + } + private static DataTree NewLongView() { var roots = new List(); diff --git a/Src/FdoUi/DetailRules/DetailObjectMerge.cs b/Src/FdoUi/DetailRules/DetailObjectMerge.cs new file mode 100644 index 0000000000..1c998ad23b --- /dev/null +++ b/Src/FdoUi/DetailRules/DetailObjectMerge.cs @@ -0,0 +1,69 @@ +// 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 SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; + +// 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 Merge command of a detail row (message DataTreeMerge): whether the row's object + /// has something to merge into now, and the menu text that says so. Execution stays with + /// CmObjectUi.MergeUnderlyingObject, which asks for the survivor. + /// + public static class DetailObjectMerge + { + /// The strings-table key of the text appended to a disabled Merge + /// item. + public const string CannotMergeKey = "(cannot merge this)"; + + /// + /// Whether can merge into another object now: it sits in an + /// owning vector that holds another object of its class, or an allomorph of the lexeme + /// form's class, or a subsense (which merges into its sense). A required vector keeps + /// its last item. + /// + public static bool CanMergeNow(ICmObject target) + { + if (target == null || !target.IsValidObject) + return false; + var owner = target.Owner; + var flid = target.OwningFlid; + var cache = target.Cache; + if (owner == null || !cache.IsVectorProperty(flid)) + return false; + var classId = target.ClassID; + if (flid == LexEntryTags.kflidAlternateForms) + { + var lexemeForm = ((ILexEntry)owner).LexemeFormOA; + if (lexemeForm != null && lexemeForm.ClassID == classId) + return true; + } + if (flid == LexSenseTags.kflidSenses) + return true; + var sda = cache.DomainDataByFlid; + var count = sda.get_VecSize(owner.Hvo, flid); + if (owner.IsFieldRequired(flid) && count < 2) + return false; + var repository = cache.ServiceLocator.ObjectRepository; + for (var i = 0; i < count; i++) + { + var hvo = sda.get_VecItem(owner.Hvo, flid, i); + if (hvo != target.Hvo && repository.GetObject(hvo).ClassID == classId) + return true; + } + return false; + } + + /// The Merge item's text: the configured label, with the cannot-merge note + /// appended when is false. + public static string MergeLabel(string label, bool canMerge) + { + var text = label ?? string.Empty; + return canMerge ? text : text + StringTable.Table.GetString(CannotMergeKey); + } + } +} diff --git a/Src/FdoUi/DetailRules/LexicalRelationEditing.cs b/Src/FdoUi/DetailRules/LexicalRelationEditing.cs new file mode 100644 index 0000000000..4bafcfde86 --- /dev/null +++ b/Src/FdoUi/DetailRules/LexicalRelationEditing.cs @@ -0,0 +1,409 @@ +// 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 SIL.FieldWorks.FdoUi; +using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; +using SIL.LCModel.Utils; + +// 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 +{ + /// What Delete Relation does on a lexical-relation row. + public enum LexicalRelationDeleteKind + { + /// The shown object leaves the relation, which goes on among the + /// others. + RemoveFromRelation, + /// The whole relation is deleted. + DeleteRelation + } + + /// + /// The chooser Add Reference / Replace Reference opens on a lexical-relation row: which + /// dialog, what it may select, and its title and button. + /// + public sealed class LexicalRelationChooser + { + public LexicalRelationChooser(bool useLinkDialog, bool sensesOnly, bool allowSenses, bool isReplace, + string title, string buttonText) + { + UseLinkDialog = useLinkDialog; + SensesOnly = sensesOnly; + AllowSenses = allowSenses; + IsReplace = isReplace; + Title = title; + ButtonText = buttonText; + } + + /// True for the entry-or-sense link dialog; false for the entry-only + /// chooser. + public bool UseLinkDialog { get; } + + /// The link dialog offers senses only. + public bool SensesOnly { get; } + + /// The link dialog offers senses as well as entries. + public bool AllowSenses { get; } + + /// The choice replaces the row's reference instead of adding one. + public bool IsReplace { get; } + + public string Title { get; } + + public string ButtonText { get; } + } + + /// + /// The commands of a lexical-relation row (Delete Relation, Add Reference, Replace + /// Reference, Edit Reference Set Details): how each edits the relation from the object + /// whose detail shows it, and what the dialogs they open need. The relation's mapping + /// type and which side the shown object is on decide the behaviour. + /// + public static class LexicalRelationEditing + { + /// The help topic of the Add / Replace Reference chooser. + public const string ChooserHelpTopic = "khtpChooseLexicalRelationAdd"; + + /// The undo text of adding a reference to a relation. + public static string UndoAddReference => FdoUiStrings.ksUndoAddRef; + + /// The redo text of adding a reference to a relation. + public static string RedoAddReference => FdoUiStrings.ksRedoAddRef; + + /// The undo text of replacing a relation's reference. + public static string UndoReplaceReference => FdoUiStrings.ksUndoReplaceRef; + + /// The redo text of replacing a relation's reference. + public static string RedoReplaceReference => FdoUiStrings.ksRedoReplaceRef; + + /// The chooser's button when it adds a reference. + public static string AddButtonText => FdoUiStrings.ks_Add; + + /// The chooser's button when it replaces a reference. + public static string ReplaceButtonText => FdoUiStrings.ks_Replace; + + /// The chooser's title when it adds a reference to a relation of the named + /// type. + public static string AddReferenceTitle(string typeName) + => string.Format(FdoUiStrings.ksIdentifyXEntry, typeName); + + /// The chooser's title when it adds a reference to a relation of the named + /// type, worded for what the type relates: senses, entries or either. + public static string AddReferenceTitle(LexRefTypeTags.MappingTypes mapping, string typeName) + { + switch (mapping) + { + case LexRefTypeTags.MappingTypes.kmtSenseCollection: + case LexRefTypeTags.MappingTypes.kmtSensePair: + case LexRefTypeTags.MappingTypes.kmtSenseAsymmetricPair: + case LexRefTypeTags.MappingTypes.kmtSenseSequence: + case LexRefTypeTags.MappingTypes.kmtSenseTree: + case LexRefTypeTags.MappingTypes.kmtSenseUnidirectional: + return string.Format(FdoUiStrings.ksIdentifyXSense, typeName); + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseCollection: + case LexRefTypeTags.MappingTypes.kmtEntryOrSensePair: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseAsymmetricPair: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseSequence: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseTree: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseUnidirectional: + return string.Format(FdoUiStrings.ksIdentifyXLexEntryOrSense, typeName); + default: + return string.Format(FdoUiStrings.ksIdentifyXLexEntry, typeName); + } + } + + /// The relation's mapping type. + public static LexRefTypeTags.MappingTypes MappingTypeOf(ILexReference relation) + => (LexRefTypeTags.MappingTypes)((ILexRefType)relation.Owner).MappingType; + + /// Whether the relation is a tree (whole / parts). + public static bool IsTree(LexRefTypeTags.MappingTypes mapping) + => mapping == LexRefTypeTags.MappingTypes.kmtSenseTree + || mapping == LexRefTypeTags.MappingTypes.kmtEntryTree + || mapping == LexRefTypeTags.MappingTypes.kmtEntryOrSenseTree; + + /// Whether the relation is a pair, symmetric or not. + public static bool IsPair(LexRefTypeTags.MappingTypes mapping) + { + switch (mapping) + { + case LexRefTypeTags.MappingTypes.kmtSensePair: + case LexRefTypeTags.MappingTypes.kmtEntryPair: + case LexRefTypeTags.MappingTypes.kmtEntryOrSensePair: + case LexRefTypeTags.MappingTypes.kmtSenseAsymmetricPair: + case LexRefTypeTags.MappingTypes.kmtEntryAsymmetricPair: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseAsymmetricPair: + return true; + default: + return false; + } + } + + /// Whether heads the relation: the whole of a + /// tree, the first of a sequence or pair. + public static bool IsFirstTarget(ILexReference relation, ICmObject current) + => relation.TargetsRS.Count > 0 && relation.TargetsRS[0] == current; + + /// + /// What Delete Relation does for : it leaves a collection + /// or sequence, and a tree it is a part of; a pair, a unidirectional relation and a + /// tree it is the whole of are deleted. + /// + public static LexicalRelationDeleteKind DeleteKind(ILexReference relation, ICmObject current) + { + var mapping = MappingTypeOf(relation); + switch (mapping) + { + case LexRefTypeTags.MappingTypes.kmtSenseCollection: + case LexRefTypeTags.MappingTypes.kmtEntryCollection: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseCollection: + case LexRefTypeTags.MappingTypes.kmtSenseSequence: + case LexRefTypeTags.MappingTypes.kmtEntrySequence: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseSequence: + return LexicalRelationDeleteKind.RemoveFromRelation; + default: + return IsTree(mapping) && !IsFirstTarget(relation, current) + ? LexicalRelationDeleteKind.RemoveFromRelation + : LexicalRelationDeleteKind.DeleteRelation; + } + } + + /// + /// The note the delete confirmation shows under the object: for a collection or + /// sequence with more than two members, that the others stay related; for a tree + /// or unidirectional relation being deleted, that the whole set goes. Null when + /// there is nothing to add. + /// + public static ITsString DeleteNote(ILexReference relation, ICmObject current) + { + var type = (ILexRefType)relation.Owner; + var cache = relation.Cache; + var userWs = cache.WritingSystemFactory.UserWs; + var builder = TsStringUtils.MakeIncStrBldr(); + builder.SetIntPropValues((int)FwTextPropType.ktptWs, 0, userWs); + var kind = DeleteKind(relation, current); + switch (MappingTypeOf(relation)) + { + case LexRefTypeTags.MappingTypes.kmtSenseCollection: + case LexRefTypeTags.MappingTypes.kmtEntryCollection: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseCollection: + case LexRefTypeTags.MappingTypes.kmtSenseSequence: + case LexRefTypeTags.MappingTypes.kmtEntrySequence: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseSequence: + if (relation.TargetsRS.Count <= 2) + return null; + builder.Append(string.Format(FdoUiStrings.ksDeleteSequenceCollectionA, StringUtils.kChHardLB.ToString())); + builder.SetIntPropValues((int)FwTextPropType.ktptWs, 0, + cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem.Handle); + builder.Append(type.ShortName); + builder.SetIntPropValues((int)FwTextPropType.ktptWs, 0, userWs); + builder.Append(FdoUiStrings.ksDeleteSequenceCollectionB); + return builder.GetString(); + case LexRefTypeTags.MappingTypes.kmtSenseTree: + case LexRefTypeTags.MappingTypes.kmtEntryTree: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseTree: + case LexRefTypeTags.MappingTypes.kmtSenseUnidirectional: + case LexRefTypeTags.MappingTypes.kmtEntryUnidirectional: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseUnidirectional: + if (kind != LexicalRelationDeleteKind.DeleteRelation) + return null; + builder.Append(string.Format(FdoUiStrings.ksDeleteLexTree, StringUtils.kChHardLB)); + return builder.GetString(); + default: + return null; + } + } + + /// Runs Delete Relation for as + /// says, in one undoable step. + public static void Delete(ILexReference relation, ICmObject current) + { + if (relation == null) + throw new ArgumentNullException(nameof(relation)); + if (current == null) + throw new ArgumentNullException(nameof(current)); + var kind = DeleteKind(relation, current); + UndoableUnitOfWorkHelper.Do(FdoUiStrings.ksUndoDeleteRelation, FdoUiStrings.ksRedoDeleteRelation, current, () => + { + if (kind == LexicalRelationDeleteKind.RemoveFromRelation) + relation.TargetsRS.Remove(current); + else + relation.Cache.DomainDataByFlid.DeleteObj(relation.Hvo); + }); + } + + /// + /// The chooser Add / Replace Reference opens for : a pair + /// replaces the other member, a tree's part replaces its whole, everything else adds. + /// Sense types offer senses, entry types entries, the rest both. + /// + public static LexicalRelationChooser Chooser(ILexReference relation, ICmObject current) + { + var mapping = MappingTypeOf(relation); + var type = (ILexRefType)relation.Owner; + var name = type.Name.BestAnalysisAlternative.Text; + bool useLinkDialog = false, sensesOnly = false, allowSenses = false; + switch (mapping) + { + case LexRefTypeTags.MappingTypes.kmtSenseCollection: + case LexRefTypeTags.MappingTypes.kmtSensePair: + case LexRefTypeTags.MappingTypes.kmtSenseAsymmetricPair: + case LexRefTypeTags.MappingTypes.kmtSenseSequence: + case LexRefTypeTags.MappingTypes.kmtSenseTree: + case LexRefTypeTags.MappingTypes.kmtSenseUnidirectional: + useLinkDialog = true; + sensesOnly = true; + break; + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseCollection: + case LexRefTypeTags.MappingTypes.kmtEntryOrSensePair: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseAsymmetricPair: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseSequence: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseTree: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseUnidirectional: + useLinkDialog = true; + allowSenses = true; + break; + } + var isReplace = IsReplace(relation, current); + string title; + switch (mapping) + { + case LexRefTypeTags.MappingTypes.kmtSenseCollection: + case LexRefTypeTags.MappingTypes.kmtEntryCollection: + case LexRefTypeTags.MappingTypes.kmtEntryOrSenseCollection: + title = AddReferenceTitle(mapping, name); + break; + default: + title = isReplace ? FdoUiStrings.ksReplaceXEntry : AddReferenceTitle(name); + break; + } + return new LexicalRelationChooser(useLinkDialog, sensesOnly, allowSenses, isReplace, title, + isReplace ? ReplaceButtonText : AddButtonText); + } + + /// Whether the chooser replaces: a pair with its other member in place, or + /// a tree seen from a part. + public static bool IsReplace(ILexReference relation, ICmObject current) + { + var mapping = MappingTypeOf(relation); + if (IsPair(mapping)) + return PairPartner(relation, current) != null; + return IsTree(mapping) && !IsFirstTarget(relation, current); + } + + /// The other member of a pair, null when the pair is not yet complete. + public static ICmObject PairPartner(ILexReference relation, ICmObject current) + { + if (relation.TargetsRS.Count < 2) + return null; + var partner = relation.TargetsRS[0]; + return partner == current ? relation.TargetsRS[1] : partner; + } + + /// + /// Makes the reference for , in + /// one undoable step: a pair's other member is replaced, a tree's part joins the + /// relation whose whole is the choice, and any other relation gains the choice as a + /// member unless it is one already. + /// + /// The relation is now in; null when nothing + /// changed. + public static ILexReference AddOrReplace(ILexReference relation, ICmObject current, ICmObject chosen) + { + if (relation == null) + throw new ArgumentNullException(nameof(relation)); + if (current == null) + throw new ArgumentNullException(nameof(current)); + if (chosen == null) + throw new ArgumentNullException(nameof(chosen)); + var mapping = MappingTypeOf(relation); + ILexReference result = relation; + if (IsPair(mapping)) + { + var replace = PairPartner(relation, current) != null; + UndoableUnitOfWorkHelper.Do(replace ? FdoUiStrings.ksUndoReplaceRef : FdoUiStrings.ksUndoAddRef, + replace ? FdoUiStrings.ksRedoReplaceRef : FdoUiStrings.ksRedoAddRef, relation, + () => ReplacePairPartner(relation, current, chosen)); + } + else if (IsTree(mapping) && !IsFirstTarget(relation, current)) + { + UndoableUnitOfWorkHelper.Do(FdoUiStrings.ksUndoReplaceRef, FdoUiStrings.ksRedoReplaceRef, relation, + () => result = ReplaceTreeWhole(relation, current, chosen)); + } + else + { + if (relation.TargetsRS.Contains(chosen)) + return null; + UndoableUnitOfWorkHelper.Do(FdoUiStrings.ksUndoAddRef, FdoUiStrings.ksRedoAddRef, relation, + () => relation.TargetsRS.Add(chosen)); + } + return result; + } + + /// Replaces the member of the pair that is not + /// with , in one step so the pair never drops to one + /// member and deletes itself (LT-13729). + public static void ReplacePairPartner(ILexReference relation, ICmObject current, ICmObject chosen) + { + var index = relation.TargetsRS[0] == current ? 1 : 0; + relation.TargetsRS.Replace(index, index < relation.TargetsRS.Count ? 1 : 0, new[] { chosen }); + } + + /// + /// Makes the whole that belongs to. + /// A relation of the type with that whole takes the part in; else the part's relation + /// is retargeted when the part is its only one (LT-14926), or the part leaves it for + /// a new relation. Choosing the part's own whole changes nothing (LT-10987). + /// + /// The relation the part is now in. + public static ILexReference ReplaceTreeWhole(ILexReference relation, ICmObject part, ICmObject whole) + { + var type = (ILexRefType)relation.Owner; + var existing = type.MembersOC.FirstOrDefault(r => r.TargetsRS.Count > 0 && r.TargetsRS[0] == whole); + if (existing == null) + { + if (relation.TargetsRS.Count == 2) + { + // Setting the first slot keeps the two members, so the relation survives. + relation.TargetsRS[0] = whole; + return relation; + } + relation.TargetsRS.Remove(part); + existing = type.Services.GetInstance().Create(); + type.MembersOC.Add(existing); + existing.TargetsRS.Add(whole); + } + else + { + if (existing == relation) + return relation; + if (relation.TargetsRS.Count == 2) + type.MembersOC.Remove(relation); + else + relation.TargetsRS.Remove(part); + } + existing.TargetsRS.Add(part); + return existing; + } + + /// Sets the relation's name and comment (Edit Reference Set Details) in + /// one undoable step. + public static void SetDetails(ILexReference relation, string name, string comment) + { + if (relation == null) + throw new ArgumentNullException(nameof(relation)); + UndoableUnitOfWorkHelper.Do(FdoUiStrings.ksUndoEditRefSetDetails, FdoUiStrings.ksRedoEditRefSetDetails, relation, () => + { + relation.Name.SetAnalysisDefaultWritingSystem(name); + relation.Comment.SetAnalysisDefaultWritingSystem(comment); + }); + } + } +} diff --git a/Src/FdoUi/DetailRules/VariantBackReferences.cs b/Src/FdoUi/DetailRules/VariantBackReferences.cs new file mode 100644 index 0000000000..cfa22e873c --- /dev/null +++ b/Src/FdoUi/DetailRules/VariantBackReferences.cs @@ -0,0 +1,73 @@ +// 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.Collections.Generic; +using System.Linq; +using SIL.FieldWorks.FdoUi; +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 rows an entry shows for its variants (the VariantFormEntryBackRefs section): each + /// shows a part of another entry, and its commands act on that entry. Delete Reference + /// (message DataTreeDeleteReference) unlinks the variant from the shown entry. + /// + public static class VariantBackReferences + { + /// + /// The entry a row shows on the shown entry's behalf: the owner of the row's object + /// when that is an entry other than . Null when the root is no + /// entry, or the row's object is the root or belongs to it. + /// + public static ILexEntry BackReferencedEntry(ICmObject root, ICmObject rowObject) + { + if (!(root is ILexEntry) || rowObject == null || rowObject == root) + return null; + var owner = rowObject.Owner; + if (owner == null || owner == root) + return null; + return owner as ILexEntry; + } + + /// The object a row's commands act on: the back-referenced entry when the row + /// shows one, else the row's object. + public static ICmObject CommandTarget(ICmObject root, ICmObject rowObject) + => BackReferencedEntry(root, rowObject) ?? rowObject; + + /// The refs of that list + /// among their components, in the variant's order. + public static IReadOnlyList ReferencesTo(ILexEntry root, ILexEntry variant) + { + if (root == null || variant == null) + return new ILexEntryRef[0]; + return variant.EntryRefsOS.Where(r => r.ComponentLexemesRS.Contains(root)).ToList(); + } + + /// + /// Unlinks the variant from in one undoable step: the root + /// leaves the ref's components, and the ref leaves its entry. + /// + public static void DeleteReference(ILexEntry root, ILexEntryRef reference) + { + if (root == null) + throw new ArgumentNullException(nameof(root)); + if (reference == null) + throw new ArgumentNullException(nameof(reference)); + UndoableUnitOfWorkHelper.Do(FdoUiStrings.ksUndoDeleteRef, FdoUiStrings.ksRedoDeleteRef, reference, () => + { + reference.ComponentLexemesRS.Remove(root); + if (reference.PrimaryLexemesRS.Contains(root)) + reference.PrimaryLexemesRS.Remove(root); + var entry = reference.OwningEntry; + if (entry.EntryRefsOS.Contains(reference)) + entry.EntryRefsOS.Remove(reference); + }); + } + } +} diff --git a/Src/FdoUi/FdoUiCore.cs b/Src/FdoUi/FdoUiCore.cs index 8055dae156..c05248f5db 100644 --- a/Src/FdoUi/FdoUiCore.cs +++ b/Src/FdoUi/FdoUiCore.cs @@ -1067,6 +1067,16 @@ public bool DeleteUnderlyingObject(Func confirm) /// deleted; true when they confirm. /// public bool ConfirmDelete() + { + return ConfirmDelete(null); + } + + /// + /// Asks the user to confirm deleting the object, telling them when it cannot be + /// deleted, else showing under it; true when they confirm. + /// + /// What else the delete affects; null for nothing. + public bool ConfirmDelete(ITsString note) { CheckDisposed(); @@ -1074,10 +1084,12 @@ public bool ConfirmDelete() using (var dlg = new ConfirmDeleteObjectDlg(m_propertyTable.GetValue("HelpTopicProvider"))) { string cannotDeleteMsg; - if (CanDelete(out cannotDeleteMsg)) + if (!CanDelete(out cannotDeleteMsg)) + note = TsStringUtils.MakeString(cannotDeleteMsg, m_cache.DefaultUserWs); + if (note == null) dlg.SetDlgInfo(this, m_cache, Mediator, m_propertyTable); else - dlg.SetDlgInfo(this, m_cache, Mediator, m_propertyTable, TsStringUtils.MakeString(cannotDeleteMsg, m_cache.DefaultUserWs)); + dlg.SetDlgInfo(this, m_cache, Mediator, m_propertyTable, note); return DialogResult.Yes == dlg.ShowDialog(mainWindow); } } @@ -1182,24 +1194,47 @@ public void MergeUnderlyingObject(bool fLoseNoTextData) { CheckDisposed(); + if (MergeUnderlyingObject(fLoseNoTextData, null) != 0) + { + // Refresh in case "Sense Number" was 0 in the target and non-0 in the source + // (LT-22155). + Publisher.Publish(new PublisherParameterObject(EventConstants.MasterRefresh, null, m_propertyTable.GetWindow())); + } + } + + /// + /// Merges the object into the survivor picks among + /// the sorted candidates lists; null asks the user with + /// . A pick of 0 cancels. + /// + /// The survivor's hvo, or 0 when nothing was merged. + public int MergeUnderlyingObject(bool fLoseNoTextData, Func, int> chooseSurvivor) + { + CheckDisposed(); + + var wp = new WindowParams(); + var mergeCandidates = new List(); + string guiControl, helpTopic; + DummyCmObject dObj = GetMergeinfo(wp, mergeCandidates, out guiControl, out helpTopic); + mergeCandidates.Sort(); + var survivor = chooseSurvivor != null + ? chooseSurvivor(mergeCandidates) + : ChooseSurvivorWithDialog(wp, dObj, mergeCandidates, guiControl, helpTopic); + if (survivor == 0) + return 0; + ReallyMergeUnderlyingObject(survivor, fLoseNoTextData); + return survivor; + } + + private int ChooseSurvivorWithDialog(WindowParams wp, DummyCmObject dObj, List mergeCandidates, + string guiControl, string helpTopic) + { var mainWindow = m_propertyTable.GetValue
("window"); using (new WaitCursor(mainWindow)) + using (var dlg = new MergeObjectDlg(m_propertyTable.GetValue("HelpTopicProvider"))) { - using (var dlg = new MergeObjectDlg(m_propertyTable.GetValue("HelpTopicProvider"))) - { - var wp = new WindowParams(); - var mergeCandidates = new List(); - string guiControl, helpTopic; - DummyCmObject dObj = GetMergeinfo(wp, mergeCandidates, out guiControl, out helpTopic); - mergeCandidates.Sort(); - dlg.SetDlgInfo(m_cache, m_mediator, m_propertyTable, wp, dObj, mergeCandidates, guiControl, helpTopic); - if (DialogResult.OK == dlg.ShowDialog(mainWindow)) - { - ReallyMergeUnderlyingObject(dlg.Hvo, fLoseNoTextData); - // Refresh in case "Sense Number" was 0 in the target and non-0 in the source (LT-22155). - Publisher.Publish(new PublisherParameterObject(EventConstants.MasterRefresh, null, m_propertyTable.GetWindow())); - } - } + dlg.SetDlgInfo(m_cache, m_mediator, m_propertyTable, wp, dObj, mergeCandidates, guiControl, helpTopic); + return DialogResult.OK == dlg.ShowDialog(mainWindow) ? dlg.Hvo : 0; } } @@ -2673,7 +2708,7 @@ protected override DummyCmObject GetMergeinfo(WindowParams wp, List + /// Looks up a localized string similar to &Add. + /// + internal static string ks_Add { + get { + return ResourceManager.GetString("ks_Add", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to &Replace. + /// + internal static string ks_Replace { + get { + return ResourceManager.GetString("ks_Replace", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Note: this will remove this entire {0} reference set tree.. + /// + internal static string ksDeleteLexTree { + get { + return ResourceManager.GetString("ksDeleteLexTree", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Note: the listed references will still be {0} related in a . + /// + internal static string ksDeleteSequenceCollectionA { + get { + return ResourceManager.GetString("ksDeleteSequenceCollectionA", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to relation.. + /// + internal static string ksDeleteSequenceCollectionB { + get { + return ResourceManager.GetString("ksDeleteSequenceCollectionB", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add Reference ({0}). + /// + internal static string ksIdentifyXEntry { + get { + return ResourceManager.GetString("ksIdentifyXEntry", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add Reference ({0}). + /// + internal static string ksIdentifyXLexEntry { + get { + return ResourceManager.GetString("ksIdentifyXLexEntry", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add Reference ({0}). + /// + internal static string ksIdentifyXLexEntryOrSense { + get { + return ResourceManager.GetString("ksIdentifyXLexEntryOrSense", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add Reference ({0}). + /// + internal static string ksIdentifyXSense { + get { + return ResourceManager.GetString("ksIdentifyXSense", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Replace Reference. + /// + internal static string ksReplaceXEntry { + get { + return ResourceManager.GetString("ksReplaceXEntry", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Redo Add Reference. + /// + internal static string ksRedoAddRef { + get { + return ResourceManager.GetString("ksRedoAddRef", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Redo Delete Relation. + /// + internal static string ksRedoDeleteRelation { + get { + return ResourceManager.GetString("ksRedoDeleteRelation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Redo Replace Reference. + /// + internal static string ksRedoReplaceRef { + get { + return ResourceManager.GetString("ksRedoReplaceRef", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Undo Add Reference. + /// + internal static string ksUndoAddRef { + get { + return ResourceManager.GetString("ksUndoAddRef", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Undo Delete Relation. + /// + internal static string ksUndoDeleteRelation { + get { + return ResourceManager.GetString("ksUndoDeleteRelation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Undo Replace Reference. + /// + internal static string ksUndoReplaceRef { + get { + return ResourceManager.GetString("ksUndoReplaceRef", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Undo Edit Reference Set Details. + /// + internal static string ksUndoEditRefSetDetails { + get { + return ResourceManager.GetString("ksUndoEditRefSetDetails", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Redo Edit Reference Set Details. + /// + internal static string ksRedoEditRefSetDetails { + get { + return ResourceManager.GetString("ksRedoEditRefSetDetails", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Undo Delete Reference. + /// + internal static string ksUndoDeleteRef { + get { + return ResourceManager.GetString("ksUndoDeleteRef", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Redo Delete Reference. + /// + internal static string ksRedoDeleteRef { + get { + return ResourceManager.GetString("ksRedoDeleteRef", resourceCulture); + } + } } } diff --git a/Src/FdoUi/FdoUiStrings.resx b/Src/FdoUi/FdoUiStrings.resx index d3f1b4cf1a..2054817a1a 100644 --- a/Src/FdoUi/FdoUiStrings.resx +++ b/Src/FdoUi/FdoUiStrings.resx @@ -367,4 +367,67 @@ Without these, we cannot find related entries. The affix process rule will be lost! Do you still want to continue? + + &Add + button label + + + &Replace + + + Note: this will remove this entire {0} reference set tree. + {0} is a line feed + + + Note: the listed references will still be {0} related in a + {0} is a line feed. + + + relation. + + + Add Reference ({0}) + + + Add Reference ({0}) + + + Add Reference ({0}) + + + Add Reference ({0}) + + + Replace Reference + + + Redo Add Reference + + + Redo Delete Relation + + + Redo Replace Reference + + + Undo Add Reference + + + Undo Delete Relation + + + Undo Replace Reference + + + Undo Edit Reference Set Details + + + Redo Edit Reference Set Details + + + Undo Delete Reference + + + Redo Delete Reference + \ No newline at end of file diff --git a/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectMergeTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectMergeTests.cs new file mode 100644 index 0000000000..7788626d66 --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectMergeTests.cs @@ -0,0 +1,115 @@ +// 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 NUnit.Framework; +using SIL.FieldWorks.Common.DetailRules; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.FdoUi +{ + /// + /// The Merge rule of a detail row: the allomorph, subsense, required-vector and + /// same-class cases of , and the item text. + /// + [TestFixture] + public class DetailObjectMergeTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry _entry; + private IMoStemAllomorph _stemAllomorph; + private IMoAffixAllomorph _affixAllomorph; + private ILexSense _sense; + private ILexSense _subsense; + private ILexPronunciation _pronunciation; + + // An entry with a stem lexeme form, a stem allomorph and an affix allomorph, one sense + // with one subsense, and one pronunciation. The entry has no entry refs, so it + // requires its senses. + [SetUp] + public void MakeEntry() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + _entry = Cache.ServiceLocator.GetInstance().Create(); + _entry.LexemeFormOA = Cache.ServiceLocator.GetInstance().Create(); + _stemAllomorph = Cache.ServiceLocator.GetInstance().Create(); + _entry.AlternateFormsOS.Add(_stemAllomorph); + _affixAllomorph = Cache.ServiceLocator.GetInstance().Create(); + _entry.AlternateFormsOS.Add(_affixAllomorph); + _sense = Cache.ServiceLocator.GetInstance().Create(); + _entry.SensesOS.Add(_sense); + _subsense = Cache.ServiceLocator.GetInstance().Create(); + _sense.SensesOS.Add(_subsense); + _pronunciation = Cache.ServiceLocator.GetInstance().Create(); + _entry.PronunciationsOS.Add(_pronunciation); + }); + } + + [TearDown] + public void DeleteEntry() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + if (_entry.IsValidObject) + _entry.Delete(); + }); + } + + [Test] + public void CanMergeNow_AnAllomorphOfTheLexemeFormsClass_EvenAsTheOnlyOneOfItsClass() + { + Assert.That(DetailObjectMerge.CanMergeNow(_stemAllomorph), Is.True, "the lexeme form is a candidate"); + } + + [Test] + public void CanMergeNow_RefusesAnAllomorphOfAnotherClass_UnlessAnotherOfItsClassExists() + { + Assert.That(DetailObjectMerge.CanMergeNow(_affixAllomorph), Is.False, "neither the lexeme form nor another affix allomorph"); + + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + _entry.AlternateFormsOS.Add(Cache.ServiceLocator.GetInstance().Create())); + + Assert.That(DetailObjectMerge.CanMergeNow(_affixAllomorph), Is.True); + } + + [Test] + public void CanMergeNow_AnAllomorph_WhenTheEntryHasNoLexemeForm() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => _entry.LexemeFormOA = null); + + Assert.That(DetailObjectMerge.CanMergeNow(_stemAllomorph), Is.False, "no lexeme form and no other stem allomorph"); + } + + [Test] + public void CanMergeNow_ASubsense_Always_AndASenseOnlyWithAnotherSense() + { + Assert.That(DetailObjectMerge.CanMergeNow(_subsense), Is.True, "a subsense merges into its sense"); + Assert.That(DetailObjectMerge.CanMergeNow(_sense), Is.False, "the entry's only sense"); + + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + _entry.SensesOS.Add(Cache.ServiceLocator.GetInstance().Create())); + + Assert.That(DetailObjectMerge.CanMergeNow(_sense), Is.True); + } + + [Test] + public void CanMergeNow_RefusesAnObjectWithNoPeer_AndAnythingOutsideAVector() + { + Assert.That(DetailObjectMerge.CanMergeNow(_pronunciation), Is.False, "the only pronunciation"); + Assert.That(DetailObjectMerge.CanMergeNow(_entry.LexemeFormOA), Is.False, "an atomic field"); + Assert.That(DetailObjectMerge.CanMergeNow(_entry), Is.False, "unowned"); + Assert.That(DetailObjectMerge.CanMergeNow(null), Is.False); + } + + [Test] + public void MergeLabel_AppendsTheCannotMergeNote_OnlyWhenRefused() + { + var note = StringTable.Table.GetString(DetailObjectMerge.CannotMergeKey); + Assert.That(DetailObjectMerge.MergeLabel("Merge Allomorph into...", true), Is.EqualTo("Merge Allomorph into...")); + Assert.That(DetailObjectMerge.MergeLabel("Merge Allomorph into...", false), Is.EqualTo("Merge Allomorph into..." + note)); + Assert.That(DetailObjectMerge.MergeLabel(null, false), Is.EqualTo(note)); + } + } +} diff --git a/Src/FdoUi/FdoUiTests/DetailRules/LexicalRelationEditingTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/LexicalRelationEditingTests.cs new file mode 100644 index 0000000000..6424b8dc8d --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/LexicalRelationEditingTests.cs @@ -0,0 +1,245 @@ +// 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.Core.Text; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.FdoUi +{ + /// + /// The lexical-relation rule: what Delete Relation does per relation type and side, the + /// note the confirmation adds, which chooser Add / Replace opens, how a choice joins the + /// relation (a pair's partner, a tree's whole, a member), and the details edit. + /// + [TestFixture] + public class LexicalRelationEditingTests : MemoryOnlyBackendProviderTestBase + { + private ILexRefType _synonyms; + private ILexRefType _antonym; + private ILexRefType _partWhole; + private ILexRefType _seeAlso; + private ILexEntry _face; + private ILexEntry _nose; + private ILexEntry _eye; + private ILexEntry _head; + private ILexReference _synonymSet; + private ILexReference _antonymPair; + private ILexReference _faceParts; + private ILexReference _seeAlsoRef; + + // face, nose and eye are synonyms (collection); face and nose antonyms (pair); nose + // and eye parts of face (tree); face points to eye (unidirectional). All entry types. + [SetUp] + public void MakeRelations() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + if (Cache.LangProject.LexDbOA.ReferencesOA == null) + Cache.LangProject.LexDbOA.ReferencesOA = Cache.ServiceLocator.GetInstance().Create(); + _synonyms = MakeType("Synonyms", LexRefTypeTags.MappingTypes.kmtEntryCollection); + _antonym = MakeType("Antonym", LexRefTypeTags.MappingTypes.kmtEntryPair); + _partWhole = MakeType("Whole", LexRefTypeTags.MappingTypes.kmtEntryTree); + _seeAlso = MakeType("See Also", LexRefTypeTags.MappingTypes.kmtEntryUnidirectional); + _face = MakeEntry("face"); + _nose = MakeEntry("nose"); + _eye = MakeEntry("eye"); + _head = MakeEntry("head"); + _synonymSet = MakeRelation(_synonyms, _face, _nose, _eye); + _antonymPair = MakeRelation(_antonym, _face, _nose); + _faceParts = MakeRelation(_partWhole, _face, _nose, _eye); + _seeAlsoRef = MakeRelation(_seeAlso, _face, _eye); + }); + } + + [TearDown] + public void DeleteEverything() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + foreach (var entry in Cache.ServiceLocator.GetInstance().AllInstances().ToList()) + entry.Delete(); + foreach (var type in new[] { _synonyms, _antonym, _partWhole, _seeAlso }) + { + if (type.IsValidObject) + type.Delete(); + } + }); + } + + [Test] + public void DeleteKind_LeavesACollection_DeletesAPairAndAUnidirectional_AndSplitsATreeBySide() + { + Assert.That(LexicalRelationEditing.DeleteKind(_synonymSet, _nose), Is.EqualTo(LexicalRelationDeleteKind.RemoveFromRelation)); + Assert.That(LexicalRelationEditing.DeleteKind(_antonymPair, _nose), Is.EqualTo(LexicalRelationDeleteKind.DeleteRelation)); + Assert.That(LexicalRelationEditing.DeleteKind(_seeAlsoRef, _face), Is.EqualTo(LexicalRelationDeleteKind.DeleteRelation)); + Assert.That(LexicalRelationEditing.DeleteKind(_faceParts, _face), Is.EqualTo(LexicalRelationDeleteKind.DeleteRelation), + "the whole takes the tree with it"); + Assert.That(LexicalRelationEditing.DeleteKind(_faceParts, _nose), Is.EqualTo(LexicalRelationDeleteKind.RemoveFromRelation), + "a part just leaves"); + } + + [Test] + public void DeleteNote_NamesTheSurvivingRelation_OrTheTree_OrNothing() + { + var collectionNote = LexicalRelationEditing.DeleteNote(_synonymSet, _nose); + Assert.That(collectionNote?.Text, Does.StartWith("Note: the listed references will still be").And.Contain("Synonyms") + .And.EndWith(" relation.")); + Assert.That(LexicalRelationEditing.DeleteNote(_faceParts, _face)?.Text, + Does.StartWith("Note: this will remove this entire").And.EndWith("reference set tree.")); + Assert.That(LexicalRelationEditing.DeleteNote(_seeAlsoRef, _face), Is.Not.Null, "a unidirectional set goes whole"); + Assert.That(LexicalRelationEditing.DeleteNote(_antonymPair, _nose), Is.Null, "a pair has nothing to add"); + Assert.That(LexicalRelationEditing.DeleteNote(_faceParts, _nose), Is.Null, "a part leaving says nothing"); + + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => _synonymSet.TargetsRS.Remove(_eye)); + Assert.That(LexicalRelationEditing.DeleteNote(_synonymSet, _nose), Is.Null, "two members: nothing survives to mention"); + } + + [Test] + public void Delete_RemovesTheObjectFromACollection_AndDeletesAPair_EachInOneUndoableStep() + { + LexicalRelationEditing.Delete(_synonymSet, _nose); + Assert.That(_synonymSet.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _face, _eye })); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("Undo Delete Relation")); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(_synonymSet.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _face, _nose, _eye })); + + LexicalRelationEditing.Delete(_antonymPair, _nose); + Assert.That(_antonymPair.IsValidObject, Is.False); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(_antonym.MembersOC, Has.Count.EqualTo(1), "one Undo brings the pair back"); + } + + [Test] + public void Chooser_PicksTheDialogByWhatTheTypeRelates_AndAddsOrReplacesBySide() + { + var add = LexicalRelationEditing.Chooser(_synonymSet, _nose); + Assert.That(add.UseLinkDialog, Is.False, "an entry type uses the entry chooser"); + Assert.That(add.IsReplace, Is.False); + Assert.That(add.Title, Is.EqualTo("Add Reference (Synonyms)")); + Assert.That(add.ButtonText, Is.EqualTo("&Add")); + + var replacePartner = LexicalRelationEditing.Chooser(_antonymPair, _nose); + Assert.That(replacePartner.IsReplace, Is.True, "a complete pair replaces its other member"); + Assert.That(replacePartner.Title, Is.EqualTo("Replace Reference")); + Assert.That(replacePartner.ButtonText, Is.EqualTo("&Replace")); + + Assert.That(LexicalRelationEditing.Chooser(_faceParts, _face).IsReplace, Is.False, "the whole adds parts"); + Assert.That(LexicalRelationEditing.Chooser(_faceParts, _nose).IsReplace, Is.True, "a part replaces its whole"); + + ILexRefType senses = null, either = null; + ILexReference senseSet = null, eitherSet = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + senses = MakeType("Sense synonyms", LexRefTypeTags.MappingTypes.kmtSenseCollection); + either = MakeType("Related", LexRefTypeTags.MappingTypes.kmtEntryOrSenseSequence); + senseSet = MakeRelation(senses, _face.SensesOS[0], _nose.SensesOS[0]); + eitherSet = MakeRelation(either, _face, _nose.SensesOS[0]); + }); + var sensesOnly = LexicalRelationEditing.Chooser(senseSet, _face.SensesOS[0]); + Assert.That(sensesOnly.UseLinkDialog && sensesOnly.SensesOnly && !sensesOnly.AllowSenses, Is.True); + Assert.That(sensesOnly.Title, Is.EqualTo("Add Reference (Sense synonyms)")); + var eitherOne = LexicalRelationEditing.Chooser(eitherSet, _face); + Assert.That(eitherOne.UseLinkDialog && eitherOne.AllowSenses && !eitherOne.SensesOnly, Is.True); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + senses.Delete(); + either.Delete(); + }); + } + + [Test] + public void AddOrReplace_AppendsToACollection_UnlessTheChoiceIsAMemberAlready() + { + Assert.That(LexicalRelationEditing.AddOrReplace(_synonymSet, _nose, _head), Is.SameAs(_synonymSet)); + Assert.That(_synonymSet.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _face, _nose, _eye, _head })); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("Undo Add Reference")); + + Assert.That(LexicalRelationEditing.AddOrReplace(_synonymSet, _nose, _eye), Is.Null, "already a member"); + Assert.That(_synonymSet.TargetsRS, Has.Count.EqualTo(4)); + } + + [Test] + public void AddOrReplace_ReplacesThePartnerOfAPair_InOneStep() + { + Assert.That(LexicalRelationEditing.AddOrReplace(_antonymPair, _nose, _head), Is.SameAs(_antonymPair)); + Assert.That(_antonymPair.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _head, _nose }), "face gave way to head"); + Assert.That(_antonymPair.IsValidObject, Is.True, "never down to one member (LT-13729)"); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("Undo Replace Reference")); + + Cache.ActionHandlerAccessor.Undo(); + Assert.That(_antonymPair.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _face, _nose })); + } + + [Test] + public void AddOrReplace_MovesAPartToTheRelationOfTheChosenWhole_OrCreatesOne() + { + var moved = LexicalRelationEditing.AddOrReplace(_faceParts, _nose, _head); + Assert.That(moved, Is.Not.SameAs(_faceParts), "no relation had head as its whole, so one was created"); + Assert.That(moved.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _head, _nose })); + Assert.That(_faceParts.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _face, _eye }), "nose left face's parts"); + + Assert.That(LexicalRelationEditing.AddOrReplace(_faceParts, _eye, _head), Is.SameAs(moved), + "eye joins head's existing relation"); + Assert.That(moved.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _head, _nose, _eye })); + Assert.That(_faceParts.IsValidObject, Is.False, "face's relation lost its last part"); + } + + [Test] + public void ReplaceTreeWhole_RetargetsARelationWithOnePart_AndIgnoresTheSameWhole() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => _faceParts.TargetsRS.Remove(_eye)); + + var result = LexicalRelationEditing.AddOrReplace(_faceParts, _nose, _head); + Assert.That(result, Is.SameAs(_faceParts), "the only part keeps its relation, retargeted (LT-14926)"); + Assert.That(_faceParts.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _head, _nose })); + + Assert.That(LexicalRelationEditing.AddOrReplace(_faceParts, _nose, _head), Is.SameAs(_faceParts), + "choosing the current whole changes nothing (LT-10987)"); + Assert.That(_faceParts.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _head, _nose })); + } + + [Test] + public void SetDetails_SetsNameAndComment_InOneUndoableStep() + { + LexicalRelationEditing.SetDetails(_synonymSet, "body parts", "the usual three"); + + Assert.That(_synonymSet.Name.AnalysisDefaultWritingSystem.Text, Is.EqualTo("body parts")); + Assert.That(_synonymSet.Comment.AnalysisDefaultWritingSystem.Text, Is.EqualTo("the usual three")); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("Undo Edit Reference Set Details")); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(_synonymSet.Name.AnalysisDefaultWritingSystem.Text, Is.Null.Or.Empty); + } + + private ILexRefType MakeType(string name, LexRefTypeTags.MappingTypes mapping) + { + var type = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.LexDbOA.ReferencesOA.PossibilitiesOS.Add(type); + type.MappingType = (int)mapping; + type.Name.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString(name, Cache.DefaultAnalWs)); + return type; + } + + private ILexEntry MakeEntry(string form) + { + var entry = Cache.ServiceLocator.GetInstance().Create(); + entry.LexemeFormOA = Cache.ServiceLocator.GetInstance().Create(); + entry.LexemeFormOA.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString(form, Cache.DefaultVernWs)); + entry.SensesOS.Add(Cache.ServiceLocator.GetInstance().Create()); + return entry; + } + + private ILexReference MakeRelation(ILexRefType type, params ICmObject[] targets) + { + var relation = Cache.ServiceLocator.GetInstance().Create(); + type.MembersOC.Add(relation); + foreach (var target in targets) + relation.TargetsRS.Add(target); + return relation; + } + } +} diff --git a/Src/FdoUi/FdoUiTests/DetailRules/VariantBackReferencesTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/VariantBackReferencesTests.cs new file mode 100644 index 0000000000..a5c9e3e4fb --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/VariantBackReferencesTests.cs @@ -0,0 +1,108 @@ +// 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 variant back-reference rule: which rows act on another entry, which refs link a + /// variant to the shown entry, and what Delete Reference removes. + /// + [TestFixture] + public class VariantBackReferencesTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry _root; + private ILexEntry _variant; + private ILexEntryRef _variantRef; + private ILexEntryRef _unrelatedRef; + + // A root entry and a variant of it: the variant's ref lists the root as its component + // and primary lexeme; a second ref of the variant points elsewhere. + [SetUp] + public void MakeEntries() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var entries = Cache.ServiceLocator.GetInstance(); + var forms = Cache.ServiceLocator.GetInstance(); + var refs = Cache.ServiceLocator.GetInstance(); + _root = entries.Create(); + _root.LexemeFormOA = forms.Create(); + _root.SensesOS.Add(Cache.ServiceLocator.GetInstance().Create()); + _variant = entries.Create(); + _variant.LexemeFormOA = forms.Create(); + _variant.EntryRefsOS.Add(_variantRef = refs.Create()); + _variantRef.RefType = LexEntryRefTags.krtVariant; + _variantRef.ComponentLexemesRS.Add(_root); + _variantRef.PrimaryLexemesRS.Add(_root); + var other = entries.Create(); + _variant.EntryRefsOS.Add(_unrelatedRef = refs.Create()); + _unrelatedRef.RefType = LexEntryRefTags.krtVariant; + _unrelatedRef.ComponentLexemesRS.Add(other); + }); + } + + [TearDown] + public void DeleteEntries() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + foreach (var entry in Cache.ServiceLocator.GetInstance().AllInstances().ToList()) + entry.Delete(); + }); + } + + [Test] + public void BackReferencedEntry_IsTheOwnerOfARowObject_WhenThatIsAnotherEntry() + { + Assert.That(VariantBackReferences.BackReferencedEntry(_root, _variant.LexemeFormOA), Is.SameAs(_variant), + "the variant's form row"); + Assert.That(VariantBackReferences.BackReferencedEntry(_root, _variantRef), Is.SameAs(_variant), + "the variant's ref rows"); + Assert.That(VariantBackReferences.CommandTarget(_root, _variant.LexemeFormOA), Is.SameAs(_variant)); + } + + [Test] + public void BackReferencedEntry_IsNull_ForTheRootsOwnRows_AndWithoutARootEntry() + { + Assert.That(VariantBackReferences.BackReferencedEntry(_root, _root.LexemeFormOA), Is.Null, "owned by the root"); + Assert.That(VariantBackReferences.BackReferencedEntry(_root, _root), Is.Null, "the root itself"); + Assert.That(VariantBackReferences.BackReferencedEntry(_root, _variant), Is.Null, "an entry has no owner"); + Assert.That(VariantBackReferences.BackReferencedEntry(_root.SensesOS[0], _variant.LexemeFormOA), Is.Null, + "the root is no entry"); + Assert.That(VariantBackReferences.BackReferencedEntry(_root, null), Is.Null); + Assert.That(VariantBackReferences.CommandTarget(_root, _root.LexemeFormOA), Is.SameAs(_root.LexemeFormOA)); + } + + [Test] + public void ReferencesTo_ListsTheVariantsRefsNamingTheRoot() + { + Assert.That(VariantBackReferences.ReferencesTo(_root, _variant), Is.EqualTo(new[] { _variantRef })); + Assert.That(VariantBackReferences.ReferencesTo(_variant, _root), Is.Empty, "the root has no refs"); + Assert.That(VariantBackReferences.ReferencesTo(null, _variant), Is.Empty); + } + + [Test] + public void DeleteReference_UnlinksTheRoot_AndRemovesTheRef_InOneUndoableStep() + { + VariantBackReferences.DeleteReference(_root, _variantRef); + + Assert.That(_variantRef.IsValidObject, Is.False, "the ref is gone"); + Assert.That(_variant.EntryRefsOS.ToList(), Is.EqualTo(new[] { _unrelatedRef }), "the other ref stays"); + Assert.That(_variant.IsValidObject, Is.True, "the variant entry stays"); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("Undo Delete Reference")); + + Cache.ActionHandlerAccessor.Undo(); + + Assert.That(_variant.EntryRefsOS, Has.Count.EqualTo(2), "one Undo restores the ref"); + Assert.That(_variant.EntryRefsOS[0].ComponentLexemesRS.ToList(), Is.EqualTo(new[] { _root })); + Assert.That(_variant.EntryRefsOS[0].PrimaryLexemesRS.ToList(), Is.EqualTo(new[] { _root })); + } + } +} diff --git a/Src/LexText/Lexicon/LexEdStrings.Designer.cs b/Src/LexText/Lexicon/LexEdStrings.Designer.cs index 528e171866..6b059a4d9c 100644 --- a/Src/LexText/Lexicon/LexEdStrings.Designer.cs +++ b/Src/LexText/Lexicon/LexEdStrings.Designer.cs @@ -195,15 +195,6 @@ internal static string kProjectAlreadyHasLiftRepo { } } - /// - /// Looks up a localized string similar to &Add. - /// - internal static string ks_Add { - get { - return ResourceManager.GetString("ks_Add", resourceCulture); - } - } - /// /// Looks up a localized string similar to &Link. /// @@ -222,15 +213,6 @@ internal static string ks_MoveEntry { } } - /// - /// Looks up a localized string similar to &Replace. - /// - internal static string ks_Replace { - get { - return ResourceManager.GetString("ks_Replace", resourceCulture); - } - } - /// /// Looks up a localized string similar to Add a Complex Form.... /// @@ -564,33 +546,6 @@ internal static string ksDeleteEntrySenseConfirmTitle { } } - /// - /// Looks up a localized string similar to Note: this will remove this entire {0} reference set tree.. - /// - internal static string ksDeleteLexTree { - get { - return ResourceManager.GetString("ksDeleteLexTree", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Note: the listed references will still be {0} related in a . - /// - internal static string ksDeleteSequenceCollectionA { - get { - return ResourceManager.GetString("ksDeleteSequenceCollectionA", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to relation.. - /// - internal static string ksDeleteSequenceCollectionB { - get { - return ResourceManager.GetString("ksDeleteSequenceCollectionB", resourceCulture); - } - } - /// /// Looks up a localized string similar to Edit {0}. /// @@ -699,42 +654,6 @@ internal static string ksIdentifySense { } } - /// - /// Looks up a localized string similar to Add Reference ({0}). - /// - internal static string ksIdentifyXEntry { - get { - return ResourceManager.GetString("ksIdentifyXEntry", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Add Reference ({0}). - /// - internal static string ksIdentifyXLexEntry { - get { - return ResourceManager.GetString("ksIdentifyXLexEntry", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Add Reference ({0}). - /// - internal static string ksIdentifyXLexEntryOrSense { - get { - return ResourceManager.GetString("ksIdentifyXLexEntryOrSense", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Add Reference ({0}). - /// - internal static string ksIdentifyXSense { - get { - return ResourceManager.GetString("ksIdentifyXSense", resourceCulture); - } - } - /// /// Looks up a localized string similar to {0}/{1} relation. /// @@ -870,15 +789,6 @@ internal static string ksRedoAddParticipants { } } - /// - /// Looks up a localized string similar to Redo Add Reference. - /// - internal static string ksRedoAddRef { - get { - return ResourceManager.GetString("ksRedoAddRef", resourceCulture); - } - } - /// /// Looks up a localized string similar to Redo Add Reversal Entry to Sense. /// @@ -960,15 +870,6 @@ internal static string ksRedoDeleteRef { } } - /// - /// Looks up a localized string similar to Redo Delete Relation. - /// - internal static string ksRedoDeleteRelation { - get { - return ResourceManager.GetString("ksRedoDeleteRelation", resourceCulture); - } - } - /// /// Looks up a localized string similar to Redo Delete Reversal Entry from Sense. /// @@ -996,15 +897,6 @@ internal static string ksRedoEditFunction { } } - /// - /// Looks up a localized string similar to Redo Edit Reference Set Details. - /// - internal static string ksRedoEditRefSetDetails { - get { - return ResourceManager.GetString("ksRedoEditRefSetDetails", resourceCulture); - } - } - /// /// Looks up a localized string similar to Redo Reassign Homographs. /// @@ -1068,15 +960,6 @@ internal static string ksRedoPromote { } } - /// - /// Looks up a localized string similar to Redo Replace Reference. - /// - internal static string ksRedoReplaceRef { - get { - return ResourceManager.GetString("ksRedoReplaceRef", resourceCulture); - } - } - /// /// Looks up a localized string similar to Redo Set Reversal Entries. /// @@ -1104,15 +987,6 @@ internal static string ksRemovingCircularComponentLexeme { } } - /// - /// Looks up a localized string similar to Replace Reference. - /// - internal static string ksReplaceXEntry { - get { - return ResourceManager.GetString("ksReplaceXEntry", resourceCulture); - } - } - /// /// Looks up a localized string similar to Send/Receive not permitted. /// @@ -1221,15 +1095,6 @@ internal static string ksUndoAddParticipants { } } - /// - /// Looks up a localized string similar to Undo Add Reference. - /// - internal static string ksUndoAddRef { - get { - return ResourceManager.GetString("ksUndoAddRef", resourceCulture); - } - } - /// /// Looks up a localized string similar to Undo Add Reversal Entry to Sense. /// @@ -1311,15 +1176,6 @@ internal static string ksUndoDeleteRef { } } - /// - /// Looks up a localized string similar to Undo Delete Relation. - /// - internal static string ksUndoDeleteRelation { - get { - return ResourceManager.GetString("ksUndoDeleteRelation", resourceCulture); - } - } - /// /// Looks up a localized string similar to Undo Delete Reversal Entry from Sense. /// @@ -1347,15 +1203,6 @@ internal static string ksUndoEditFunction { } } - /// - /// Looks up a localized string similar to Undo Edit Reference Set Details. - /// - internal static string ksUndoEditRefSetDetails { - get { - return ResourceManager.GetString("ksUndoEditRefSetDetails", resourceCulture); - } - } - /// /// Looks up a localized string similar to Undo Reassign Homographs. /// @@ -1419,15 +1266,6 @@ internal static string ksUndoPromote { } } - /// - /// Looks up a localized string similar to Undo Replace Reference. - /// - internal static string ksUndoReplaceRef { - get { - return ResourceManager.GetString("ksUndoReplaceRef", resourceCulture); - } - } - /// /// Looks up a localized string similar to Undo Set Reversal Entries. /// diff --git a/Src/LexText/Lexicon/LexEdStrings.resx b/Src/LexText/Lexicon/LexEdStrings.resx index eb85d9e8b8..9cfd216d33 100644 --- a/Src/LexText/Lexicon/LexEdStrings.resx +++ b/Src/LexText/Lexicon/LexEdStrings.resx @@ -117,10 +117,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - &Add - button label - &Link button label @@ -128,9 +124,6 @@ &Move Entry - - &Replace - Always visible menu item @@ -148,17 +141,6 @@ Create New Lexical Relation... menu item - - Note: this will remove this entire {0} reference set tree. - {0} is a line feed - - - Note: the listed references will still be {0} related in a - {0} is a line feed. - - - relation. - Edit {0} @@ -186,18 +168,6 @@ Identify sense - - Add Reference ({0}) - - - Add Reference ({0}) - - - Add Reference ({0}) - - - Add Reference ({0}) - {0}/{1} relation {0}/{1} Parts/Whole names for a tree relation @@ -245,9 +215,6 @@ Redo Set Reversal Entries - - Replace Reference - Senses @@ -310,30 +277,12 @@ Undo Variant Of - - Redo Add Reference - - - Redo Delete Relation - Redo {0} - - Redo Replace Reference - - - Undo Add Reference - - - Undo Delete Relation - Undo {0} - - Undo Replace Reference - Redo Delete Reference @@ -388,12 +337,6 @@ Undo Create Reversal Index - - Undo Edit Reference Set Details - - - Redo Edit Reference Set Details - Redo Demote diff --git a/Src/LexText/Lexicon/LexReferenceCollectionLauncher.cs b/Src/LexText/Lexicon/LexReferenceCollectionLauncher.cs index 34092a3c58..c2c52a6a71 100644 --- a/Src/LexText/Lexicon/LexReferenceCollectionLauncher.cs +++ b/Src/LexText/Lexicon/LexReferenceCollectionLauncher.cs @@ -7,6 +7,7 @@ using System.Windows.Forms; using System.Diagnostics; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; using SIL.FieldWorks.LexText.Controls; @@ -42,45 +43,28 @@ internal void LaunchChooser() /// protected override void HandleChooser() { - var lrt = (ILexRefType)m_obj.Owner; - var type = (LexRefTypeTags.MappingTypes)lrt.MappingType; - bool useLinkDlg = false, sensesOnly = false, allowSenses = false; - string sTitle = string.Empty; - switch (type) - { - case LexRefTypeTags.MappingTypes.kmtSenseCollection: - useLinkDlg = true; sensesOnly = true; - sTitle = String.Format(LexEdStrings.ksIdentifyXSense, lrt.Name.BestAnalysisAlternative.Text); - break; - case LexRefTypeTags.MappingTypes.kmtEntryCollection: - sTitle = String.Format(LexEdStrings.ksIdentifyXLexEntry, lrt.Name.BestAnalysisAlternative.Text); - break; - case LexRefTypeTags.MappingTypes.kmtEntryOrSenseCollection: - useLinkDlg = true; allowSenses = true; - sTitle = String.Format(LexEdStrings.ksIdentifyXLexEntryOrSense, lrt.Name.BestAnalysisAlternative.Text); - break; - } + var chooser = LexicalRelationEditing.Chooser((ILexReference)m_obj, m_displayParent); // New-UI gate (mirrors the EntrySequence gate): in New mode the LinkEntryOrSense relations use the // Avalonia Choose-Lexical-Entry-or-Sense dialog (sense / entry-or-sense per mapping type); the // entry-only kmtEntryCollection keeps its EntryGoDlg (out of this slice's scope). Legacy keeps WinForms. var uiMode = m_propertyTable.GetStringProperty("UIMode", null); - if (useLinkDlg && UIModeGates.ShouldUseAvaloniaUI(uiMode)) + if (chooser.UseLinkDialog && UIModeGates.ShouldUseAvaloniaUI(uiMode)) { - ShowAvaloniaLinkDialog(allowSenses, sensesOnly, sTitle); + ShowAvaloniaLinkDialog(chooser.AllowSenses, chooser.SensesOnly, chooser.Title); return; } BaseGoDlg dlg = null; try { - dlg = useLinkDlg ? (BaseGoDlg)new LinkEntryOrSenseDlg() : new EntryGoDlg(); - if (useLinkDlg) - ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = sensesOnly; + dlg = chooser.UseLinkDialog ? (BaseGoDlg)new LinkEntryOrSenseDlg() : new EntryGoDlg(); + if (chooser.UseLinkDialog) + ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = chooser.SensesOnly; Debug.Assert(dlg != null); - var wp = new WindowParams { m_title = sTitle, m_btnText = LexEdStrings.ks_Add }; + var wp = new WindowParams { m_title = chooser.Title, m_btnText = chooser.ButtonText }; dlg.SetDlgInfo(m_cache, wp, m_mediator, m_propertyTable); - dlg.SetHelpTopic("khtpChooseLexicalRelationAdd"); + dlg.SetHelpTopic(LexicalRelationEditing.ChooserHelpTopic); if (dlg.ShowDialog(FindForm()) == DialogResult.OK && dlg.SelectedObject != null) { if (!((ILexReference)m_obj).TargetsRS.Contains(dlg.SelectedObject)) @@ -96,7 +80,7 @@ protected override void HandleChooser() public override void AddItem(ICmObject obj) { - AddItem(obj, LexEdStrings.ksUndoAddRef, LexEdStrings.ksRedoAddRef); + AddItem(obj, LexicalRelationEditing.UndoAddReference, LexicalRelationEditing.RedoAddReference); } protected override VectorReferenceView CreateVectorReferenceView() @@ -137,7 +121,7 @@ private void ShowAvaloniaLinkDialog(bool allowSenses, bool sensesOnly, string sT var helpProvider = m_propertyTable.GetValue("HelpTopicProvider", null); var chosen = LcmLinkEntryOrSenseDialogLauncher.Show(m_cache, m_mediator, m_propertyTable, null, FindForm(), "khtpChooseLexicalRelationAdd", helpProvider, allowSenses: allowSenses, - sensesOnly: sensesOnly, title: sTitle, okButtonText: LexEdStrings.ks_Add); + sensesOnly: sensesOnly, title: sTitle, okButtonText: LexicalRelationEditing.AddButtonText); if (chosen != null && !((ILexReference)m_obj).TargetsRS.Contains(chosen)) AddItem(chosen); } diff --git a/Src/LexText/Lexicon/LexReferenceCollectionSlice.cs b/Src/LexText/Lexicon/LexReferenceCollectionSlice.cs index 88256af914..4a97ade820 100644 --- a/Src/LexText/Lexicon/LexReferenceCollectionSlice.cs +++ b/Src/LexText/Lexicon/LexReferenceCollectionSlice.cs @@ -43,7 +43,7 @@ public override void FinishInit() public override bool HandleDeleteCommand(Command cmd) { CheckDisposed(); - ((LexReferenceMultiSlice)m_parentSlice).DeleteFromReference(GetObjectForMenusToOperateOn() as ILexReference); + ((LexReferenceMultiSlice)m_parentSlice).DeleteRelation(GetObjectForMenusToOperateOn() as ILexReference); return true; // delete was done } diff --git a/Src/LexText/Lexicon/LexReferenceMultiSlice.cs b/Src/LexText/Lexicon/LexReferenceMultiSlice.cs index 3d8c9611ea..f57ee62816 100644 --- a/Src/LexText/Lexicon/LexReferenceMultiSlice.cs +++ b/Src/LexText/Lexicon/LexReferenceMultiSlice.cs @@ -15,6 +15,7 @@ using SIL.LCModel; using SIL.LCModel.Infrastructure; using SIL.FieldWorks.Common.Controls; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.FdoUi; using SIL.FieldWorks.FdoUi.Dialogs; using SIL.LCModel.Utils; @@ -628,7 +629,7 @@ private ICmObject ShowAvaloniaLinkDialog(ILexEntry startingEntry, bool allowSens var helpProvider = m_propertyTable.GetValue("HelpTopicProvider", null); return LcmLinkEntryOrSenseDialogLauncher.Show(m_cache, Mediator, m_propertyTable, startingEntry, FindForm(), "khtpChooseLexicalRelationAdd", helpProvider, allowSenses: allowSenses, - sensesOnly: sensesOnly, title: sTitle, okButtonText: LexEdStrings.ks_Add); + sensesOnly: sensesOnly, title: sTitle, okButtonText: LexicalRelationEditing.AddButtonText); } /// @@ -659,7 +660,7 @@ private ICmObject GetRootObject(ILexRefType lrt) Debug.Assert(lrt.MappingType == (int)LexRefTypeTags.MappingTypes.kmtSenseAsymmetricPair || lrt.MappingType == (int)LexRefTypeTags.MappingTypes.kmtSenseTree); return null; } - var sTitle = String.Format(LexEdStrings.ksIdentifyXEntry, lrt.ReverseName.BestAnalysisAlternative.Text); + var sTitle = LexicalRelationEditing.AddReferenceTitle(lrt.ReverseName.BestAnalysisAlternative.Text); // New-UI gate (mirrors the EntrySequence gate): the LinkEntryOrSense relations use the Avalonia // Choose-Lexical-Entry-or-Sense dialog in New mode; the entry-only mappings keep their EntryGoDlg. @@ -674,7 +675,7 @@ private ICmObject GetRootObject(ILexRefType lrt) if (useLinkDlg) ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = sensesOnly; Debug.Assert(dlg != null); - var wp = new WindowParams { m_title = sTitle, m_btnText = LexEdStrings.ks_Add }; + var wp = new WindowParams { m_title = sTitle, m_btnText = LexicalRelationEditing.AddButtonText }; dlg.SetDlgInfo(m_cache, wp, Mediator, m_propertyTable); dlg.SetHelpTopic("khtpChooseLexicalRelationAdd"); if (dlg.ShowDialog(FindForm()) == DialogResult.OK) @@ -706,7 +707,7 @@ private ICmObject GetChildObject(ILexRefType lrt) case LexRefTypeTags.MappingTypes.kmtEntryOrSenseAsymmetricPair: // Entry or sense pair with different Forward/Reverse useLinkDlg = true; allowSenses = true; - sTitle = String.Format(LexEdStrings.ksIdentifyXLexEntryOrSense, lrt.Name.BestAnalysisAlternative.Text); + sTitle = LexicalRelationEditing.AddReferenceTitle((LexRefTypeTags.MappingTypes)lrt.MappingType, lrt.Name.BestAnalysisAlternative.Text); break; case LexRefTypeTags.MappingTypes.kmtSenseCollection: @@ -717,7 +718,7 @@ private ICmObject GetChildObject(ILexRefType lrt) case LexRefTypeTags.MappingTypes.kmtSenseSequence: case LexRefTypeTags.MappingTypes.kmtSenseTree: useLinkDlg = true; sensesOnly = true; - sTitle = String.Format(LexEdStrings.ksIdentifyXSense, lrt.Name.BestAnalysisAlternative.Text); + sTitle = LexicalRelationEditing.AddReferenceTitle((LexRefTypeTags.MappingTypes)lrt.MappingType, lrt.Name.BestAnalysisAlternative.Text); break; case LexRefTypeTags.MappingTypes.kmtEntryCollection: @@ -727,7 +728,7 @@ private ICmObject GetChildObject(ILexRefType lrt) // Entry pair with different Forward/Reverse names case LexRefTypeTags.MappingTypes.kmtEntrySequence: case LexRefTypeTags.MappingTypes.kmtEntryTree: - sTitle = String.Format(LexEdStrings.ksIdentifyXLexEntry, lrt.Name.BestAnalysisAlternative.Text); + sTitle = LexicalRelationEditing.AddReferenceTitle((LexRefTypeTags.MappingTypes)lrt.MappingType, lrt.Name.BestAnalysisAlternative.Text); break; case LexRefTypeTags.MappingTypes.kmtEntryOrSenseCollection: @@ -735,7 +736,7 @@ private ICmObject GetChildObject(ILexRefType lrt) case LexRefTypeTags.MappingTypes.kmtEntryOrSenseTree: case LexRefTypeTags.MappingTypes.kmtEntryOrSenseUnidirectional: useLinkDlg = true; allowSenses = true; - sTitle = String.Format(LexEdStrings.ksIdentifyXLexEntryOrSense, lrt.Name.BestAnalysisAlternative.Text); + sTitle = LexicalRelationEditing.AddReferenceTitle((LexRefTypeTags.MappingTypes)lrt.MappingType, lrt.Name.BestAnalysisAlternative.Text); break; default: Debug.Assert(lrt.MappingType == (int)LexRefTypeTags.MappingTypes.kmtSenseAsymmetricPair || lrt.MappingType == (int)LexRefTypeTags.MappingTypes.kmtSenseTree); @@ -762,7 +763,7 @@ private ICmObject GetChildObject(ILexRefType lrt) if (useLinkDlg) ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = sensesOnly; Debug.Assert(dlg != null); - var wp = new WindowParams { m_title = sTitle, m_btnText = LexEdStrings.ks_Add }; + var wp = new WindowParams { m_title = sTitle, m_btnText = LexicalRelationEditing.AddButtonText }; dlg.StartingEntry = startingEntry; dlg.SetDlgInfo(m_cache, wp, Mediator, m_propertyTable); @@ -838,144 +839,29 @@ public override void Expand(int iSlice) /// - /// This method is called when a user selects Delete Relation on a Lexical Relation slice. - /// For: sequence relations (eg. Calendar) - /// collection relations (eg. Synonym) - /// tree relation (parts/whole when deleting a Whole slice) + /// Delete Relation on one of this slice's relation rows: once the user confirms, this + /// object leaves the relation or the relation goes, as + /// decides from its type and side. /// - /// - public void DeleteFromReference(ILexReference lr) - { - CheckDisposed(); - if (lr == null) - { - throw new ConfigurationException("Slice:GetObjectHvoForMenusToOperateOn is either messed up or should not have been called, because it could not find the object to be deleted.", m_configurationNode); - } - else - { - var mainWindow = m_propertyTable.GetValue("window"); - using (new WaitCursor(mainWindow)) - { - using (var dlg = new ConfirmDeleteObjectDlg(m_propertyTable.GetValue("HelpTopicProvider"))) - { - - var ui = CmObjectUi.MakeUi(m_cache, lr.Hvo); - ui.Mediator = Mediator; - ui.PropTable = m_propertyTable; - - //We need this to determine which kind of relation we are deleting - var lrtOwner = (ILexRefType) lr.Owner; - - var analWs = lrtOwner.Services.WritingSystems.DefaultAnalysisWritingSystem.Handle; - var userWs = m_cache.WritingSystemFactory.UserWs; - var tisb = TsStringUtils.MakeIncStrBldr(); - tisb.SetIntPropValues((int)FwTextPropType.ktptWs, 0, userWs); - - switch ((LexRefTypeTags.MappingTypes)lrtOwner.MappingType) - { - case LexRefTypeTags.MappingTypes.kmtSenseSequence: - case LexRefTypeTags.MappingTypes.kmtEntrySequence: - case LexRefTypeTags.MappingTypes.kmtEntryOrSenseSequence: - case LexRefTypeTags.MappingTypes.kmtEntryOrSenseCollection: - case LexRefTypeTags.MappingTypes.kmtEntryCollection: - case LexRefTypeTags.MappingTypes.kmtSenseCollection: - if (lr.TargetsRS.Count > 2) - { - tisb.SetIntPropValues((int)FwTextPropType.ktptWs, 0, userWs); - tisb.Append(String.Format(LexEdStrings.ksDeleteSequenceCollectionA, - StringUtils.kChHardLB.ToString())); - tisb.SetIntPropValues((int)FwTextPropType.ktptWs, 0, analWs); - tisb.Append(lrtOwner.ShortName); - tisb.SetIntPropValues((int)FwTextPropType.ktptWs, 0, userWs); - tisb.Append(LexEdStrings.ksDeleteSequenceCollectionB); - - dlg.SetDlgInfo(ui, m_cache, Mediator, m_propertyTable, tisb.GetString()); - } - else - { - dlg.SetDlgInfo(ui, m_cache, Mediator, m_propertyTable); - } - break; - default: - dlg.SetDlgInfo(ui, m_cache, Mediator, m_propertyTable); - break; - } - - if (DialogResult.Yes == dlg.ShowDialog(mainWindow)) - { - UndoableUnitOfWorkHelper.Do(LexEdStrings.ksUndoDeleteRelation, LexEdStrings.ksRedoDeleteRelation, m_obj, () => - { - //If the user selected Yes, then we need to delete 'this' sense or entry - lr.TargetsRS.Remove(m_obj); - }); - //Update the display because we have removed this slice from the Lexical entry. - UpdateForDelete(lr); - } - } - } - } - } - - /// - /// This method is called when a user selects Delete Relation on a Lexical Relation slice. - /// For: Pair relation (eg. Antonym) - /// tree relation (parts/whole when deleting a Parts slice) - /// - /// - public void DeleteReference(ILexReference lr) + public void DeleteRelation(ILexReference lr) { CheckDisposed(); if (lr == null) { throw new ConfigurationException("Slice:GetObjectHvoForMenusToOperateOn is either messed up or should not have been called, because it could not find the object to be deleted.", m_configurationNode); } - else + var mainWindow = m_propertyTable.GetValue("window"); + using (new WaitCursor(mainWindow)) + using (var ui = CmObjectUi.MakeUi(m_cache, lr.Hvo)) { - var mainWindow = m_propertyTable.GetValue("window"); - using (new WaitCursor(mainWindow)) - { - using (var dlg = new ConfirmDeleteObjectDlg(m_propertyTable.GetValue("HelpTopicProvider"))) - { - var ui = CmObjectUi.MakeUi(m_cache, lr.Hvo); - ui.Mediator = Mediator; - ui.PropTable = m_propertyTable; - - //We need this to determine which kind of relation we are deleting - var lrtOwner = lr.Owner as ILexRefType; - - var userWs = m_cache.WritingSystemFactory.UserWs; - var tisb = TsStringUtils.MakeIncStrBldr(); - tisb.SetIntPropValues((int)FwTextPropType.ktptWs, 0, userWs); - - switch ((LexRefTypeTags.MappingTypes)lrtOwner.MappingType) - { - case LexRefTypeTags.MappingTypes.kmtSenseTree: - case LexRefTypeTags.MappingTypes.kmtEntryTree: - case LexRefTypeTags.MappingTypes.kmtEntryOrSenseTree: - case LexRefTypeTags.MappingTypes.kmtSenseUnidirectional: - case LexRefTypeTags.MappingTypes.kmtEntryUnidirectional: - case LexRefTypeTags.MappingTypes.kmtEntryOrSenseUnidirectional: - tisb.SetIntPropValues((int)FwTextPropType.ktptWs, 0, userWs); - tisb.Append(String.Format(LexEdStrings.ksDeleteLexTree, StringUtils.kChHardLB)); - dlg.SetDlgInfo(ui, m_cache, Mediator, m_propertyTable, tisb.GetString()); - break; - default: - dlg.SetDlgInfo(ui, m_cache, Mediator, m_propertyTable); - break; - } - - if (DialogResult.Yes == dlg.ShowDialog(mainWindow)) - { - UndoableUnitOfWorkHelper.Do(LexEdStrings.ksUndoDeleteRelation, LexEdStrings.ksRedoDeleteRelation, m_obj, () => - { - m_cache.DomainDataByFlid.DeleteObj(lr.Hvo); - }); - //Update the display because we have removed this slice from the Lexical entry. - UpdateForDelete(lr); - } - } - } + ui.Mediator = Mediator; + ui.PropTable = m_propertyTable; + if (!ui.ConfirmDelete(LexicalRelationEditing.DeleteNote(lr, m_obj))) + return; } + LexicalRelationEditing.Delete(lr, m_obj); + //Update the display because we have removed this slice from the Lexical entry. + UpdateForDelete(lr); } private void UpdateForDelete(ILexReference lr) @@ -1007,15 +893,7 @@ public void EditReferenceDetails(ILexReference lr) dlg.ReferenceName = lr.Name.AnalysisDefaultWritingSystem.Text; dlg.ReferenceComment = lr.Comment.AnalysisDefaultWritingSystem.Text; if (dlg.ShowDialog() == DialogResult.OK) - { - using (UndoableUnitOfWorkHelper helper = new UndoableUnitOfWorkHelper(m_cache.ActionHandlerAccessor, - LexEdStrings.ksUndoEditRefSetDetails, LexEdStrings.ksRedoEditRefSetDetails)) - { - lr.Name.SetAnalysisDefaultWritingSystem(dlg.ReferenceName); - lr.Comment.SetAnalysisDefaultWritingSystem(dlg.ReferenceComment); - helper.RollBack = false; - } - } + LexicalRelationEditing.SetDetails(lr, dlg.ReferenceName, dlg.ReferenceComment); } } } diff --git a/Src/LexText/Lexicon/LexReferencePairLauncher.cs b/Src/LexText/Lexicon/LexReferencePairLauncher.cs index 484d7872db..4dd1123c7d 100644 --- a/Src/LexText/Lexicon/LexReferencePairLauncher.cs +++ b/Src/LexText/Lexicon/LexReferencePairLauncher.cs @@ -4,11 +4,11 @@ using System; using System.Runtime.CompilerServices; -using System.Collections.Generic; using System.Windows.Forms; using System.Diagnostics; using SIL.LCModel; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.FieldWorks.Common.FwUtils; using SIL.FieldWorks.LexText.Controls; @@ -44,17 +44,7 @@ protected override ICmObject Target { Debug.Assert(value != null); - int index = 0; - var lr = m_obj as ILexReference; - var item = lr.TargetsRS[0]; - if (item == m_displayParent) - index = 1; -#if WANTPORTMULTI - (lr as ILexReference).UpdateTargetTimestamps(); - (co as ICmObject).UpdateTimestampForVirtualChange(); -#endif - // LT-13729: Remove old and then Insert new might cause the deletion of the lr, then the insert fails. - lr.TargetsRS.Replace(index, (index < lr.TargetsRS.Count) ? 1 : 0, new List() { value }); + LexicalRelationEditing.ReplacePairPartner((ILexReference)m_obj, m_displayParent, value); } } @@ -73,56 +63,29 @@ internal void LaunchChooser() /// protected override void HandleChooser() { - ILexRefType lrt = (ILexRefType)m_obj.Owner; - LexRefTypeTags.MappingTypes type = (LexRefTypeTags.MappingTypes)lrt.MappingType; - bool useLinkDlg = false, sensesOnly = false, allowSenses = false; - switch (type) - { - case LexRefTypeTags.MappingTypes.kmtSensePair: - case LexRefTypeTags.MappingTypes.kmtSenseAsymmetricPair: // Sense pair with different Forward/Reverse names - useLinkDlg = true; sensesOnly = true; - break; - case LexRefTypeTags.MappingTypes.kmtEntryPair: - case LexRefTypeTags.MappingTypes.kmtEntryAsymmetricPair: // Entry pair with different Forward/Reverse names - break; - case LexRefTypeTags.MappingTypes.kmtEntryOrSensePair: - case LexRefTypeTags.MappingTypes.kmtEntryOrSenseAsymmetricPair: // Entry or sense pair with different Forward/Reverse - useLinkDlg = true; allowSenses = true; - break; - } //on creating Pair Lexical Relation have an Add button and Add in the title bar; otherwise Replacing - string sTitle, btnText; - if (Target == null) - { - sTitle = String.Format(LexEdStrings.ksIdentifyXEntry, lrt.Name.BestAnalysisAlternative.Text); - btnText = LexEdStrings.ks_Add; - } - else - { - sTitle = String.Format(LexEdStrings.ksReplaceXEntry); - btnText = LexEdStrings.ks_Replace; - } + var chooser = LexicalRelationEditing.Chooser((ILexReference)m_obj, m_displayParent); // New-UI gate (mirrors the EntrySequence gate): the LinkEntryOrSense relations use the Avalonia // Choose-Lexical-Entry-or-Sense dialog in New mode; the entry-only entry pairs keep their EntryGoDlg. var uiMode = m_propertyTable.GetStringProperty("UIMode", null); - if (useLinkDlg && UIModeGates.ShouldUseAvaloniaUI(uiMode)) + if (chooser.UseLinkDialog && UIModeGates.ShouldUseAvaloniaUI(uiMode)) { - ShowAvaloniaLinkDialog(allowSenses, sensesOnly, sTitle, btnText); + ShowAvaloniaLinkDialog(chooser.AllowSenses, chooser.SensesOnly, chooser.Title, chooser.ButtonText); return; } BaseGoDlg dlg = null; try { - dlg = useLinkDlg ? (BaseGoDlg)new LinkEntryOrSenseDlg() : new EntryGoDlg(); - if (useLinkDlg) - ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = sensesOnly; + dlg = chooser.UseLinkDialog ? (BaseGoDlg)new LinkEntryOrSenseDlg() : new EntryGoDlg(); + if (chooser.UseLinkDialog) + ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = chooser.SensesOnly; Debug.Assert(dlg != null); - var wp = new WindowParams { m_title = sTitle, m_btnText = btnText }; + var wp = new WindowParams { m_title = chooser.Title, m_btnText = chooser.ButtonText }; dlg.SetDlgInfo(m_cache, wp, m_mediator, m_propertyTable); - dlg.SetHelpTopic("khtpChooseLexicalRelationAdd"); + dlg.SetHelpTopic(LexicalRelationEditing.ChooserHelpTopic); if (dlg.ShowDialog(FindForm()) == DialogResult.OK) { if (dlg.SelectedObject != null) @@ -146,13 +109,13 @@ public override void AddItem(ICmObject obj) string undoStr, redoStr; if (Target == null) { - undoStr = LexEdStrings.ksUndoAddRef; - redoStr = LexEdStrings.ksRedoAddRef; + undoStr = LexicalRelationEditing.UndoAddReference; + redoStr = LexicalRelationEditing.RedoAddReference; } else { - undoStr = LexEdStrings.ksUndoReplaceRef; - redoStr = LexEdStrings.ksRedoReplaceRef; + undoStr = LexicalRelationEditing.UndoReplaceReference; + redoStr = LexicalRelationEditing.RedoReplaceReference; } AddItem(obj, undoStr, redoStr); } diff --git a/Src/LexText/Lexicon/LexReferencePairSlice.cs b/Src/LexText/Lexicon/LexReferencePairSlice.cs index dd528ba311..2932f712e2 100644 --- a/Src/LexText/Lexicon/LexReferencePairSlice.cs +++ b/Src/LexText/Lexicon/LexReferencePairSlice.cs @@ -49,7 +49,7 @@ public override void FinishInit() public override bool HandleDeleteCommand(Command cmd) { CheckDisposed(); - ((LexReferenceMultiSlice)m_parentSlice).DeleteReference(GetObjectForMenusToOperateOn() as ILexReference); + ((LexReferenceMultiSlice)m_parentSlice).DeleteRelation(GetObjectForMenusToOperateOn() as ILexReference); return true; // delete was done } diff --git a/Src/LexText/Lexicon/LexReferenceSequenceLauncher.cs b/Src/LexText/Lexicon/LexReferenceSequenceLauncher.cs index b2755b562f..42d9466f3d 100644 --- a/Src/LexText/Lexicon/LexReferenceSequenceLauncher.cs +++ b/Src/LexText/Lexicon/LexReferenceSequenceLauncher.cs @@ -6,6 +6,7 @@ using System.Runtime.CompilerServices; using System.Diagnostics; using System.Windows.Forms; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; @@ -43,41 +44,27 @@ internal void LaunchChooser() /// protected override void HandleChooser() { - ILexRefType lrt = (ILexRefType)m_obj.Owner; - int type = lrt.MappingType; - bool useLinkDlg = false, sensesOnly = false, allowSenses = false; - switch ((LexRefTypeTags.MappingTypes)type) - { - case LexRefTypeTags.MappingTypes.kmtSenseSequence: - useLinkDlg = true; sensesOnly = true; - break; - case LexRefTypeTags.MappingTypes.kmtEntrySequence: - break; - case LexRefTypeTags.MappingTypes.kmtEntryOrSenseSequence: - useLinkDlg = true; allowSenses = true; - break; - } - var sTitle = String.Format(LexEdStrings.ksIdentifyXEntry, lrt.Name.BestAnalysisAlternative.Text); + var chooser = LexicalRelationEditing.Chooser((ILexReference)m_obj, m_displayParent); // New-UI gate (mirrors the EntrySequence gate): the LinkEntryOrSense relations use the Avalonia // Choose-Lexical-Entry-or-Sense dialog in New mode; the entry-only kmtEntrySequence keeps its EntryGoDlg. var uiMode = m_propertyTable.GetStringProperty("UIMode", null); - if (useLinkDlg && UIModeGates.ShouldUseAvaloniaUI(uiMode)) + if (chooser.UseLinkDialog && UIModeGates.ShouldUseAvaloniaUI(uiMode)) { - ShowAvaloniaLinkDialog(allowSenses, sensesOnly, sTitle); + ShowAvaloniaLinkDialog(chooser.AllowSenses, chooser.SensesOnly, chooser.Title); return; } BaseGoDlg dlg = null; try { - dlg = useLinkDlg ? (BaseGoDlg)new LinkEntryOrSenseDlg() : new EntryGoDlg(); - if (useLinkDlg) - ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = sensesOnly; + dlg = chooser.UseLinkDialog ? (BaseGoDlg)new LinkEntryOrSenseDlg() : new EntryGoDlg(); + if (chooser.UseLinkDialog) + ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = chooser.SensesOnly; Debug.Assert(dlg != null); - var wp = new WindowParams { m_title = sTitle, m_btnText = LexEdStrings.ks_Add }; + var wp = new WindowParams { m_title = chooser.Title, m_btnText = chooser.ButtonText }; dlg.SetDlgInfo(m_cache, wp, m_mediator, m_propertyTable); - dlg.SetHelpTopic("khtpChooseLexicalRelationAdd"); + dlg.SetHelpTopic(LexicalRelationEditing.ChooserHelpTopic); if (dlg.ShowDialog(FindForm()) == DialogResult.OK) { if (!(m_obj as ILexReference).TargetsRS.Contains(dlg.SelectedObject)) @@ -93,7 +80,7 @@ protected override void HandleChooser() public override void AddItem(ICmObject obj) { - AddItem(obj, LexEdStrings.ksUndoAddRef, LexEdStrings.ksRedoAddRef); + AddItem(obj, LexicalRelationEditing.UndoAddReference, LexicalRelationEditing.RedoAddReference); } protected override VectorReferenceView CreateVectorReferenceView() @@ -134,7 +121,7 @@ private void ShowAvaloniaLinkDialog(bool allowSenses, bool sensesOnly, string sT var helpProvider = m_propertyTable.GetValue("HelpTopicProvider", null); var chosen = LcmLinkEntryOrSenseDialogLauncher.Show(m_cache, m_mediator, m_propertyTable, null, FindForm(), "khtpChooseLexicalRelationAdd", helpProvider, allowSenses: allowSenses, - sensesOnly: sensesOnly, title: sTitle, okButtonText: LexEdStrings.ks_Add); + sensesOnly: sensesOnly, title: sTitle, okButtonText: LexicalRelationEditing.AddButtonText); if (chosen != null && !(m_obj as ILexReference).TargetsRS.Contains(chosen)) AddItem(chosen); } diff --git a/Src/LexText/Lexicon/LexReferenceSequenceSlice.cs b/Src/LexText/Lexicon/LexReferenceSequenceSlice.cs index e7da88c961..98e85cab05 100644 --- a/Src/LexText/Lexicon/LexReferenceSequenceSlice.cs +++ b/Src/LexText/Lexicon/LexReferenceSequenceSlice.cs @@ -42,7 +42,7 @@ public override void FinishInit() public override bool HandleDeleteCommand(Command cmd) { CheckDisposed(); - ((LexReferenceMultiSlice)m_parentSlice).DeleteFromReference(GetObjectForMenusToOperateOn() as ILexReference); + ((LexReferenceMultiSlice)m_parentSlice).DeleteRelation(GetObjectForMenusToOperateOn() as ILexReference); return true; // delete was done } diff --git a/Src/LexText/Lexicon/LexReferenceTreeBranchesLauncher.cs b/Src/LexText/Lexicon/LexReferenceTreeBranchesLauncher.cs index 7d5610dd19..b5db15a20d 100644 --- a/Src/LexText/Lexicon/LexReferenceTreeBranchesLauncher.cs +++ b/Src/LexText/Lexicon/LexReferenceTreeBranchesLauncher.cs @@ -6,6 +6,7 @@ using System.Runtime.CompilerServices; using System.Diagnostics; using System.Windows.Forms; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; @@ -39,41 +40,29 @@ internal void LaunchChooser() /// protected override void HandleChooser() { - ILexRefType lrt = (ILexRefType)m_obj.Owner; - int type = lrt.MappingType; - bool useLinkDlg = false, sensesOnly = false, allowSenses = false; - switch ((LexRefTypeTags.MappingTypes)type) - { - case LexRefTypeTags.MappingTypes.kmtSenseTree: - useLinkDlg = true; sensesOnly = true; - break; - case LexRefTypeTags.MappingTypes.kmtEntryTree: - break; - case LexRefTypeTags.MappingTypes.kmtEntryOrSenseTree: - useLinkDlg = true; allowSenses = true; - break; - } - var sTitle = String.Format(LexEdStrings.ksIdentifyXEntry, lrt.Name.BestAnalysisAlternative.Text); + // This launcher sits on the whole's row, so the whole is the shown object. + var relation = (ILexReference)m_obj; + var chooser = LexicalRelationEditing.Chooser(relation, relation.TargetsRS.Count > 0 ? relation.TargetsRS[0] : null); // New-UI gate (mirrors the EntrySequence gate): the LinkEntryOrSense relations use the Avalonia // Choose-Lexical-Entry-or-Sense dialog in New mode; the entry-only kmtEntryTree keeps its EntryGoDlg. var uiMode = m_propertyTable.GetStringProperty("UIMode", null); - if (useLinkDlg && UIModeGates.ShouldUseAvaloniaUI(uiMode)) + if (chooser.UseLinkDialog && UIModeGates.ShouldUseAvaloniaUI(uiMode)) { - ShowAvaloniaLinkDialog(allowSenses, sensesOnly, sTitle); + ShowAvaloniaLinkDialog(chooser.AllowSenses, chooser.SensesOnly, chooser.Title); return; } BaseGoDlg dlg = null; try { - dlg = useLinkDlg ? (BaseGoDlg)new LinkEntryOrSenseDlg() : new EntryGoDlg(); - if (useLinkDlg) - ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = sensesOnly; + dlg = chooser.UseLinkDialog ? (BaseGoDlg)new LinkEntryOrSenseDlg() : new EntryGoDlg(); + if (chooser.UseLinkDialog) + ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = chooser.SensesOnly; Debug.Assert(dlg != null); - var wp = new WindowParams { m_title = sTitle, m_btnText = LexEdStrings.ks_Add }; + var wp = new WindowParams { m_title = chooser.Title, m_btnText = chooser.ButtonText }; dlg.SetDlgInfo(m_cache, wp, m_mediator, m_propertyTable); - dlg.SetHelpTopic("khtpChooseLexicalRelationAdd"); + dlg.SetHelpTopic(LexicalRelationEditing.ChooserHelpTopic); if (dlg.ShowDialog(FindForm()) == DialogResult.OK) { if (!(m_obj as ILexReference).TargetsRS.Contains(dlg.SelectedObject)) @@ -89,7 +78,7 @@ protected override void HandleChooser() public override void AddItem(ICmObject obj) { - AddItem(obj, LexEdStrings.ksUndoAddRef, LexEdStrings.ksRedoAddRef); + AddItem(obj, LexicalRelationEditing.UndoAddReference, LexicalRelationEditing.RedoAddReference); } protected override VectorReferenceView CreateVectorReferenceView() @@ -115,7 +104,7 @@ private void ShowAvaloniaLinkDialog(bool allowSenses, bool sensesOnly, string sT var helpProvider = m_propertyTable.GetValue("HelpTopicProvider", null); var chosen = LcmLinkEntryOrSenseDialogLauncher.Show(m_cache, m_mediator, m_propertyTable, null, FindForm(), "khtpChooseLexicalRelationAdd", helpProvider, allowSenses: allowSenses, - sensesOnly: sensesOnly, title: sTitle, okButtonText: LexEdStrings.ks_Add); + sensesOnly: sensesOnly, title: sTitle, okButtonText: LexicalRelationEditing.AddButtonText); if (chosen != null && !(m_obj as ILexReference).TargetsRS.Contains(chosen)) AddItem(chosen); } diff --git a/Src/LexText/Lexicon/LexReferenceTreeBranchesSlice.cs b/Src/LexText/Lexicon/LexReferenceTreeBranchesSlice.cs index 1e4935f594..e0100c895e 100644 --- a/Src/LexText/Lexicon/LexReferenceTreeBranchesSlice.cs +++ b/Src/LexText/Lexicon/LexReferenceTreeBranchesSlice.cs @@ -25,7 +25,7 @@ public LexReferenceTreeBranchesSlice() public override bool HandleDeleteCommand(Command cmd) { CheckDisposed(); - ((LexReferenceMultiSlice)m_parentSlice).DeleteReference(GetObjectForMenusToOperateOn() as ILexReference); + ((LexReferenceMultiSlice)m_parentSlice).DeleteRelation(GetObjectForMenusToOperateOn() as ILexReference); return true; // delete was done } diff --git a/Src/LexText/Lexicon/LexReferenceTreeRootLauncher.cs b/Src/LexText/Lexicon/LexReferenceTreeRootLauncher.cs index fbb1fd3532..b04739cc37 100644 --- a/Src/LexText/Lexicon/LexReferenceTreeRootLauncher.cs +++ b/Src/LexText/Lexicon/LexReferenceTreeRootLauncher.cs @@ -8,6 +8,7 @@ using System.Diagnostics; using SIL.LCModel; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.FieldWorks.Common.FwUtils; using SIL.FieldWorks.LexText.Controls; @@ -36,64 +37,7 @@ protected override ICmObject Target { Debug.Assert(value != null); - var lr = m_obj as ILexReference; - var lrt = lr.Owner as ILexRefType; - var objToLink = GetChildObject(); - // See if there is another existing relation of the same type with the desired 'whole'. - ILexReference newRef = null; - foreach (var lr1 in lrt.MembersOC) - { - if (lr1.TargetsRS.Count > 0 && lr1.TargetsRS[0] == value) - { - newRef = lr1; - break; - } - } - if (newRef == null) - { - // No existing relationship to join - if (lr.TargetsRS.Count == 2) - { - // The first one is the old 'whole' of which this is a part. - // The second is objToLink. - // No other parts use the same relation, and there is no other relation with the new 'whole'. - // Therefore, we can just modify the relation. - Debug.Assert(lr.TargetsRS[1] == objToLink); - // Do this in one step; if we delete first, FDO gets too smart and deletes the whole lexical relation. - lr.TargetsRS[0] = value; - return; // must not try to insert into newRef. - } - else - { - // There are other parts in the relation. - // We need to remove ourself from the relation and create a new one for the new relationship. - lr.TargetsRS.Remove(objToLink); - newRef = lrt.Services.GetInstance().Create(); - lrt.MembersOC.Add(newRef); - newRef.TargetsRS.Add(value); // must be the first (root) item - } - } - else - { - if (lr == newRef) - { - // Silly user tried to replace a relation with itself, don't do anything! [LT-10987] - return; - } - // There's a new relationship objToLink needs to join. - if (lr.TargetsRS.Count == 2) - { - // We were the only 'part' of the old relationship; it is now meaningless and needs to go away - lrt.MembersOC.Remove(lr); - } - else - { - // Just remove objToLink from the old relationship - lr.TargetsRS.Remove(objToLink); - } - } - // Whatever else happens we end up identifying or creating a new relationship which we need to join. - newRef.TargetsRS.Add(objToLink); + LexicalRelationEditing.ReplaceTreeWhole((ILexReference)m_obj, GetChildObject(), value); } } @@ -134,42 +78,28 @@ internal void LaunchChooser() /// protected override void HandleChooser() { - ILexRefType lrt = (ILexRefType)m_obj.Owner; - int type = lrt.MappingType; - bool useLinkDlg = false, sensesOnly = false, allowSenses = false; - switch ((LexRefTypeTags.MappingTypes)type) - { - case LexRefTypeTags.MappingTypes.kmtSenseTree: - useLinkDlg = true; sensesOnly = true; - break; - case LexRefTypeTags.MappingTypes.kmtEntryTree: - break; - case LexRefTypeTags.MappingTypes.kmtEntryOrSenseTree: - useLinkDlg = true; allowSenses = true; - break; - } //This method is only called when we are Replacing the tree root of a Whole/Part lexical relation - var sTitle = String.Format(LexEdStrings.ksReplaceXEntry); + var chooser = LexicalRelationEditing.Chooser((ILexReference)m_obj, GetChildObject()); // New-UI gate (mirrors the EntrySequence gate): the LinkEntryOrSense relations use the Avalonia // Choose-Lexical-Entry-or-Sense dialog in New mode; the entry-only kmtEntryTree keeps its EntryGoDlg. var uiMode = m_propertyTable.GetStringProperty("UIMode", null); - if (useLinkDlg && UIModeGates.ShouldUseAvaloniaUI(uiMode)) + if (chooser.UseLinkDialog && UIModeGates.ShouldUseAvaloniaUI(uiMode)) { - ShowAvaloniaLinkDialog(allowSenses, sensesOnly, sTitle); + ShowAvaloniaLinkDialog(chooser.AllowSenses, chooser.SensesOnly, chooser.Title); return; } BaseGoDlg dlg = null; try { - dlg = useLinkDlg ? (BaseGoDlg)new LinkEntryOrSenseDlg() : new EntryGoDlg(); - if (useLinkDlg) - ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = sensesOnly; + dlg = chooser.UseLinkDialog ? (BaseGoDlg)new LinkEntryOrSenseDlg() : new EntryGoDlg(); + if (chooser.UseLinkDialog) + ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = chooser.SensesOnly; Debug.Assert(dlg != null); - var wp = new WindowParams { m_title = sTitle, m_btnText = LexEdStrings.ks_Replace }; + var wp = new WindowParams { m_title = chooser.Title, m_btnText = chooser.ButtonText }; dlg.SetDlgInfo(m_cache, wp, m_mediator, m_propertyTable); - dlg.SetHelpTopic("khtpChooseLexicalRelationAdd"); + dlg.SetHelpTopic(LexicalRelationEditing.ChooserHelpTopic); if (dlg.ShowDialog(FindForm()) == DialogResult.OK) { if (dlg.SelectedObject != null) @@ -188,13 +118,13 @@ public override void AddItem(ICmObject obj) string undoStr, redoStr; if (Target == null) { - undoStr = LexEdStrings.ksUndoAddRef; - redoStr = LexEdStrings.ksRedoAddRef; + undoStr = LexicalRelationEditing.UndoAddReference; + redoStr = LexicalRelationEditing.RedoAddReference; } else { - undoStr = LexEdStrings.ksUndoReplaceRef; - redoStr = LexEdStrings.ksRedoReplaceRef; + undoStr = LexicalRelationEditing.UndoReplaceReference; + redoStr = LexicalRelationEditing.RedoReplaceReference; } AddItem(obj, undoStr, redoStr); } @@ -221,7 +151,7 @@ private void ShowAvaloniaLinkDialog(bool allowSenses, bool sensesOnly, string sT var helpProvider = m_propertyTable.GetValue("HelpTopicProvider", null); var chosen = LcmLinkEntryOrSenseDialogLauncher.Show(m_cache, m_mediator, m_propertyTable, null, FindForm(), "khtpChooseLexicalRelationAdd", helpProvider, allowSenses: allowSenses, - sensesOnly: sensesOnly, title: sTitle, okButtonText: LexEdStrings.ks_Replace); + sensesOnly: sensesOnly, title: sTitle, okButtonText: LexicalRelationEditing.ReplaceButtonText); if (chosen != null) AddItem(chosen); } diff --git a/Src/LexText/Lexicon/LexReferenceTreeRootSlice.cs b/Src/LexText/Lexicon/LexReferenceTreeRootSlice.cs index 0640060789..2533bb0657 100644 --- a/Src/LexText/Lexicon/LexReferenceTreeRootSlice.cs +++ b/Src/LexText/Lexicon/LexReferenceTreeRootSlice.cs @@ -27,7 +27,7 @@ public LexReferenceTreeRootSlice() public override bool HandleDeleteCommand(Command cmd) { CheckDisposed(); - ((LexReferenceMultiSlice)m_parentSlice).DeleteFromReference(GetObjectForMenusToOperateOn() as ILexReference); + ((LexReferenceMultiSlice)m_parentSlice).DeleteRelation(GetObjectForMenusToOperateOn() as ILexReference); return true; // delete was done } diff --git a/Src/LexText/Lexicon/LexReferenceUnidirectionalLauncher.cs b/Src/LexText/Lexicon/LexReferenceUnidirectionalLauncher.cs index 222b3300f4..aa84bf8200 100644 --- a/Src/LexText/Lexicon/LexReferenceUnidirectionalLauncher.cs +++ b/Src/LexText/Lexicon/LexReferenceUnidirectionalLauncher.cs @@ -6,6 +6,7 @@ using System.Runtime.CompilerServices; using System.Diagnostics; using System.Windows.Forms; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; @@ -39,41 +40,29 @@ internal void LaunchChooser() /// protected override void HandleChooser() { - ILexRefType lrt = (ILexRefType)m_obj.Owner; - int type = lrt.MappingType; - bool useLinkDlg = false, sensesOnly = false, allowSenses = false; - switch ((LexRefTypeTags.MappingTypes)type) - { - case LexRefTypeTags.MappingTypes.kmtSenseUnidirectional: - useLinkDlg = true; sensesOnly = true; - break; - case LexRefTypeTags.MappingTypes.kmtEntryUnidirectional: - break; - case LexRefTypeTags.MappingTypes.kmtEntryOrSenseUnidirectional: - useLinkDlg = true; allowSenses = true; - break; - } - var sTitle = String.Format(LexEdStrings.ksIdentifyXEntry, lrt.Name.BestAnalysisAlternative.Text); + // A unidirectional relation is shown on its first member only. + var relation = (ILexReference)m_obj; + var chooser = LexicalRelationEditing.Chooser(relation, relation.TargetsRS.Count > 0 ? relation.TargetsRS[0] : null); // New-UI gate (mirrors the EntrySequence gate): the LinkEntryOrSense relations use the Avalonia // Choose-Lexical-Entry-or-Sense dialog in New mode; the entry-only kmtEntryUnidirectional keeps EntryGoDlg. var uiMode = m_propertyTable.GetStringProperty("UIMode", null); - if (useLinkDlg && UIModeGates.ShouldUseAvaloniaUI(uiMode)) + if (chooser.UseLinkDialog && UIModeGates.ShouldUseAvaloniaUI(uiMode)) { - ShowAvaloniaLinkDialog(allowSenses, sensesOnly, sTitle); + ShowAvaloniaLinkDialog(chooser.AllowSenses, chooser.SensesOnly, chooser.Title); return; } BaseGoDlg dlg = null; try { - dlg = useLinkDlg ? (BaseGoDlg)new LinkEntryOrSenseDlg() : new EntryGoDlg(); - if (useLinkDlg) - ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = sensesOnly; + dlg = chooser.UseLinkDialog ? (BaseGoDlg)new LinkEntryOrSenseDlg() : new EntryGoDlg(); + if (chooser.UseLinkDialog) + ((LinkEntryOrSenseDlg)dlg).SelectSensesOnly = chooser.SensesOnly; Debug.Assert(dlg != null); - var wp = new WindowParams { m_title = sTitle, m_btnText = LexEdStrings.ks_Add }; + var wp = new WindowParams { m_title = chooser.Title, m_btnText = chooser.ButtonText }; dlg.SetDlgInfo(m_cache, wp, m_mediator, m_propertyTable); - dlg.SetHelpTopic("khtpChooseLexicalRelationAdd"); + dlg.SetHelpTopic(LexicalRelationEditing.ChooserHelpTopic); if (dlg.ShowDialog(FindForm()) == DialogResult.OK) { if (!(m_obj as ILexReference).TargetsRS.Contains(dlg.SelectedObject)) @@ -89,7 +78,7 @@ protected override void HandleChooser() public override void AddItem(ICmObject obj) { - AddItem(obj, LexEdStrings.ksUndoAddRef, LexEdStrings.ksRedoAddRef); + AddItem(obj, LexicalRelationEditing.UndoAddReference, LexicalRelationEditing.RedoAddReference); } protected override VectorReferenceView CreateVectorReferenceView() @@ -115,7 +104,7 @@ private void ShowAvaloniaLinkDialog(bool allowSenses, bool sensesOnly, string sT var helpProvider = m_propertyTable.GetValue("HelpTopicProvider", null); var chosen = LcmLinkEntryOrSenseDialogLauncher.Show(m_cache, m_mediator, m_propertyTable, null, FindForm(), "khtpChooseLexicalRelationAdd", helpProvider, allowSenses: allowSenses, - sensesOnly: sensesOnly, title: sTitle, okButtonText: LexEdStrings.ks_Add); + sensesOnly: sensesOnly, title: sTitle, okButtonText: LexicalRelationEditing.AddButtonText); if (chosen != null && !(m_obj as ILexReference).TargetsRS.Contains(chosen)) AddItem(chosen); } diff --git a/Src/LexText/Lexicon/LexReferenceUnidirectionalSlice.cs b/Src/LexText/Lexicon/LexReferenceUnidirectionalSlice.cs index 44dad33426..60dab7f1b8 100644 --- a/Src/LexText/Lexicon/LexReferenceUnidirectionalSlice.cs +++ b/Src/LexText/Lexicon/LexReferenceUnidirectionalSlice.cs @@ -25,7 +25,7 @@ public LexReferenceUnidirectionalSlice() public override bool HandleDeleteCommand(Command cmd) { CheckDisposed(); - ((LexReferenceMultiSlice)m_parentSlice).DeleteReference(GetObjectForMenusToOperateOn() as ILexReference); + ((LexReferenceMultiSlice)m_parentSlice).DeleteRelation(GetObjectForMenusToOperateOn() as ILexReference); return true; // delete was done } diff --git a/Src/LexText/Lexicon/RecordReferenceVectorLauncher.cs b/Src/LexText/Lexicon/RecordReferenceVectorLauncher.cs index e2915501b1..9ebde3c59d 100644 --- a/Src/LexText/Lexicon/RecordReferenceVectorLauncher.cs +++ b/Src/LexText/Lexicon/RecordReferenceVectorLauncher.cs @@ -4,6 +4,7 @@ using System.Windows.Forms; using System.Linq; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.LCModel; using SIL.FieldWorks.LexText.Controls; @@ -19,7 +20,7 @@ protected override void HandleChooser() { using (var dlg = new RecordGoDlg()) { - var wp = new WindowParams { m_title = LexEdStrings.ksIdentifyRecord, m_btnText = LexEdStrings.ks_Add }; + var wp = new WindowParams { m_title = LexEdStrings.ksIdentifyRecord, m_btnText = LexicalRelationEditing.AddButtonText }; dlg.SetDlgInfo(m_cache, wp, m_mediator, m_propertyTable); dlg.SetHelpTopic(Slice.GetChooserHelpTopicID()); if (dlg.ShowDialog(FindForm()) == DialogResult.OK) @@ -30,7 +31,7 @@ protected override void HandleChooser() public override void AddItem(ICmObject obj) { if (!Targets.Contains(obj)) - AddItem(obj, LexEdStrings.ksUndoAddRef, LexEdStrings.ksRedoAddRef); + AddItem(obj, LexicalRelationEditing.UndoAddReference, LexicalRelationEditing.RedoAddReference); } } } diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index aada0f21b0..2cf6f83c9a 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -2080,7 +2080,7 @@ private void AddLexicalRelationRows(ViewNode node, ICmObject obj, int depth) if (row == null) continue; - var stableId = StableId(node, obj) + "/lexref:" + relation.Guid; + var stableId = StableId(node, obj) + LexicalRelationIdMarker + relation.Guid; var items = row.Targets .Select(target => new DetailChoiceOption(target.Guid.ToString(), ResolveEntryOrSenseName(target))) .ToList(); @@ -3707,6 +3707,26 @@ private static string Localize(string label) => string.IsNullOrEmpty(label) ? label : StringTable.Table.LocalizeAttributeValue(label); } + /// + /// What a lexical-relation row's id adds, with the relation's guid, after the id of + /// the entry or sense whose detail shows the relation. + /// + internal const string LexicalRelationIdMarker = "/lexref:"; + + /// The entry or sense a lexical-relation row belongs to, from its id. + /// False for any other row's id. + internal static bool TryGetLexicalRelationOwner(string stableId, out int hvo) + { + hvo = 0; + if (string.IsNullOrEmpty(stableId)) + return false; + var marker = stableId.IndexOf(LexicalRelationIdMarker, StringComparison.Ordinal); + if (marker < 0) + return false; + var at = stableId.LastIndexOf('@', marker); + return at >= 0 && int.TryParse(stableId.Substring(at + 1, marker - at - 1), out hvo) && hvo != 0; + } + // Mirrors legacy's GetMagicWsIdFromName/GetWritingSystemList; override falls back to all. internal static IReadOnlyList ApplyVisibleWritingSystems( IReadOnlyList systems, IReadOnlyList visible) diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs index 0156eba444..a6bd441381 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs @@ -15,6 +15,7 @@ using SIL.FieldWorks.LexText.Controls; using SIL.FieldWorks.Resources; using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; using SIL.LCModel.Infrastructure; using SIL.Reporting; using SIL.Utils; @@ -60,12 +61,66 @@ public partial class RecordEditView /// The message of Picture Properties. internal const string PicturePropertiesMessage = "PictureProperties"; + /// The message every Merge ... into leaf of the detail menus sends. + internal const string MergeMessage = "DataTreeMerge"; + + /// The message of Delete Reference on a variant's row. + internal const string DeleteReferenceMessage = "DataTreeDeleteReference"; + + /// The message of Add Reference and Replace Reference on a lexical-relation + /// row. + internal const string AddReferenceMessage = "DataTreeAddReference"; + + /// The message of Edit Reference Set Details. + internal const string EditReferenceMessage = "DataTreeEdit"; + + /// The message of Swap Allomorph with Lexeme Form. + internal const string SwapAllomorphMessage = "SwapAllomorphWithLexeme"; + + /// The message of Convert to Affix Process / Convert to Affix Allomorph on an + /// allomorph's row. + internal const string ConvertAllomorphMessage = "ConvertAllomorph"; + /// /// Asks the user to confirm a delete: the product shows the object's confirmation /// dialog; a headless test answers for the user. /// internal Func DeleteConfirmation { get; set; } = ui => ui.ConfirmDelete(); + /// + /// Asks the user to confirm deleting a relation, with the note on what else the + /// delete affects (null for none): the product shows the relation's confirmation + /// dialog; a headless test answers for the user. + /// + internal Func RelationDeleteConfirmation { get; set; } = (ui, note) => ui.ConfirmDelete(note); + + /// + /// Picks the survivor of a merge among the sorted candidates, by hvo, or 0 to cancel: + /// the product asks with (null here); a headless test + /// answers for the user. + /// + internal Func, int> MergeSurvivor { get; set; } + + /// + /// Picks the entry or sense to add to a lexical relation or put in place of its + /// reference, or null to cancel: the product asks with the chooser the request + /// describes; a headless test answers for the user. + /// + internal Func RelationTargetChosen { get; set; } + + /// + /// Edits a relation's name and comment, or returns null to cancel: the product asks + /// with ; a headless test answers for the user. + /// + internal Func RelationDetailsEdited { get; set; } + + /// The name and comment Edit Reference Set Details gives a relation. + internal sealed class RelationDetails + { + public string Name { get; set; } + public string Comment { get; set; } + } + /// /// Picks the allomorph to swap with the entry's lexeme form, or null to cancel: the /// product asks with ; a headless test @@ -125,6 +180,12 @@ internal LexiconMenuAuthority CreateLexiconMenuAuthority(DetailField field) authority.Answer(MoveDownMessage, leaf => MoveItem(leaf, field, up: false)); authority.Answer(InsertMediaFileMessage, leaf => InsertMediaItem(leaf, field)); authority.Answer(PicturePropertiesMessage, leaf => PicturePropertiesItem(leaf, field)); + authority.Answer(MergeMessage, leaf => MergeItem(leaf, field)); + authority.Answer(DeleteReferenceMessage, leaf => DeleteReferenceItem(leaf, field)); + authority.Answer(AddReferenceMessage, leaf => AddReferenceItem(leaf, field)); + authority.Answer(EditReferenceMessage, leaf => EditReferenceItem(leaf, field)); + authority.Answer(SwapAllomorphMessage, leaf => SwapAllomorphItem(leaf, field)); + authority.Answer(ConvertAllomorphMessage, leaf => ConvertLexemeFormItem(leaf, field)); return authority; } @@ -315,11 +376,16 @@ private ICmObject RowObjectOf(DetailField field) return Cache.ServiceLocator.ObjectRepository.TryGetObject(field.ObjectHvo, out var target) ? target : null; } + // The object a row's Delete or Merge acts on: the variant entry on a variant's rows, + // else the row's object. + private ICmObject CommandTargetOf(ICmObject rowObject) + => VariantBackReferences.CommandTarget(ResolveShownRecord(Clerk?.CurrentObject), rowObject); + /// - /// The Delete item for the row's object: absent on a row with no object, a ghost row - /// and a row whose object the command does not act on; disabled when the object is - /// gone from the repository or refuses - /// it. + /// The Delete item for the row's object (the variant entry on a variant's row): absent + /// on a row with no object, a ghost row and a row whose object the command does not act + /// on; disabled when the object is gone from the repository or + /// refuses it. /// internal DetailMenuItem DeleteItem(DetailMenuLeaf leaf, DetailField field) { @@ -331,6 +397,7 @@ internal DetailMenuItem DeleteItem(DetailMenuLeaf leaf, DetailField field) Logger.WriteEvent("Delete is disabled: the row's object " + field.ObjectHvo + " no longer exists."); return DetailMenuItem.Disabled(DetailObjectDeletion.DeleteLabel(label, null, false)); } + target = CommandTargetOf(target); if (!CommandActsOn(target, leaf.Command)) return null; var canDelete = DetailObjectDeletion.CanDeleteNow(target); @@ -340,9 +407,10 @@ internal DetailMenuItem DeleteItem(DetailMenuLeaf leaf, DetailField field) } /// - /// Deletes the row's object after confirmation: pending edits settle first, the - /// object's UI runs the delete, and the re-show focuses the nearest surviving row, the - /// one before the deleted object's rows when it survives. + /// Deletes the row's object (the variant entry on a variant's row) after confirmation: + /// pending edits settle first, the object's UI runs the delete, and the re-show focuses + /// the nearest surviving row, the one before the deleted object's rows when it survives. + /// A relation row runs instead. /// /// True when the object was deleted. internal bool DeleteDetailObject(DetailField field) @@ -353,6 +421,9 @@ internal bool DeleteDetailObject(DetailField field) Logger.WriteEvent("Delete did nothing: the row's object " + field.ObjectHvo + " no longer exists."); return false; } + if (target is ILexReference relation) + return DeleteRelation(field, relation); + target = CommandTargetOf(target); var focusRows = RowsToFocusAfterDeleting(m_avaloniaEntryForm?.ShownDetail, target); bool deleted; using (var ui = CmObjectUi.MakeUi(Cache, target.Hvo)) @@ -746,5 +817,328 @@ private string ChoosePictureFileWithDialog(ICmPicture picture) return dlg.ShowDialog(m_propertyTable.GetValue("window")) == DialogResult.OK ? dlg.CurrentFile : null; } } + + /// + /// The Merge item for the row's object (the variant entry on a variant's row): absent + /// on a row with no object, a ghost row and a row whose object the command does not act + /// on; disabled when refuses it. + /// + internal DetailMenuItem MergeItem(DetailMenuLeaf leaf, DetailField field) + { + var rowObject = RowObjectOf(field); + if (rowObject == null || field.GhostPrompt != null) + return null; + var target = CommandTargetOf(rowObject); + if (!CommandActsOn(target, leaf.Command)) + return null; + var canMerge = DetailObjectMerge.CanMergeNow(target); + var label = DetailObjectMerge.MergeLabel(XCoreMenuBridge.StripAccelerator(leaf.Label), canMerge); + return new DetailMenuItem(label, isEnabled: canMerge, isChecked: false, children: null, + execute: canMerge ? (Action)(() => MergeDetailObject(field)) : null); + } + + /// + /// Merges the row's object into the survivor the user picks through + /// : pending edits settle first, the object's UI runs the + /// merge in one undoable step, and the re-show focuses the survivor. + /// + /// True when the objects were merged. + internal bool MergeDetailObject(DetailField field) + { + SettleDetailEdits(); + var target = CommandTargetOf(RowObjectOf(field)); + if (target == null) + { + Logger.WriteEvent("Merge did nothing: the row's object " + field?.ObjectHvo + " no longer exists."); + return false; + } + if (!DetailObjectMerge.CanMergeNow(target)) + return false; + int survivor; + using (var ui = CmObjectUi.MakeUi(Cache, target.Hvo)) + { + ui.Mediator = m_mediator; + ui.PropTable = m_propertyTable; + survivor = ui.MergeUnderlyingObject(true, MergeSurvivor); + } + if (survivor == 0) + return false; + m_avaloniaEntryForm?.FocusObjectOnNextShow(survivor); + OnAvaloniaDetailEditCompleted(this, EventArgs.Empty); + return true; + } + + /// + /// The Delete Reference item for a variant's row: absent with no row, no object or a + /// ghost row; enabled when the row's variant has a ref to the shown entry, disabled + /// (with the cannot-delete note) on any other row. + /// + internal DetailMenuItem DeleteReferenceItem(DetailMenuLeaf leaf, DetailField field) + { + var rowObject = RowObjectOf(field); + if (rowObject == null || field.GhostPrompt != null) + return null; + var label = XCoreMenuBridge.StripAccelerator(leaf.Label); + if (VariantReferenceOf(field, rowObject) == null) + return DetailMenuItem.Disabled(label + StringTable.Table.GetString(DetailObjectDeletion.CannotDeleteKey)); + return new DetailMenuItem(label, isEnabled: true, isChecked: false, children: null, + execute: () => DeleteVariantReference(field)); + } + + /// + /// Unlinks the row's variant from the shown entry: pending edits settle first, the + /// ref goes in one undoable step, and the re-show focuses the nearest surviving row. + /// + /// True when the ref was deleted. + internal bool DeleteVariantReference(DetailField field) + { + SettleDetailEdits(); + var rowObject = RowObjectOf(field); + var reference = rowObject == null ? null : VariantReferenceOf(field, rowObject); + if (!(ResolveShownRecord(Clerk?.CurrentObject) is ILexEntry root) || reference == null) + { + Logger.WriteEvent("Delete Reference did nothing: no variant ref to the shown entry on row " + field?.StableId + "."); + return false; + } + var focusRows = RowsToFocusAfterDeleting(m_avaloniaEntryForm?.ShownDetail, reference.OwningEntry); + VariantBackReferences.DeleteReference(root, reference); + if (focusRows.Count > 0) + m_avaloniaEntryForm?.FocusRowOnNextShow(focusRows); + OnAvaloniaDetailEditCompleted(this, EventArgs.Empty); + return true; + } + + // The ref linking the row's variant to the shown entry: the only one, or among + // several the one whose own rows follow the row in the shown model, as a variant's + // Variant Info rows follow its form. + private ILexEntryRef VariantReferenceOf(DetailField field, ICmObject rowObject) + { + var root = ResolveShownRecord(Clerk?.CurrentObject) as ILexEntry; + var variant = VariantBackReferences.BackReferencedEntry(root, rowObject); + if (variant == null) + return null; + var references = VariantBackReferences.ReferencesTo(root, variant); + if (references.Count == 0) + return null; + var fields = m_avaloniaEntryForm?.ShownDetail?.Fields; + if (references.Count > 1 && fields != null) + { + var index = IndexOfRow(fields, field); + for (var i = index + 1; index >= 0 && i < fields.Count; i++) + { + foreach (var reference in references) + { + if (reference.Hvo == fields[i].ObjectHvo) + return reference; + } + } + } + return references[0]; + } + + // The row's index among the shown fields: the row itself, else the first row with its + // id (a variant linked twice shows its Form row twice under one id). + private static int IndexOfRow(IReadOnlyList fields, DetailField field) + { + var byId = -1; + for (var i = 0; i < fields.Count; i++) + { + if (ReferenceEquals(fields[i], field)) + return i; + if (byId < 0 && fields[i].StableId == field.StableId) + byId = i; + } + return byId; + } + + // The entry or sense a lexical-relation row belongs to, from its id; null for any + // other row or when it is gone. + private ICmObject LexicalRelationOwnerOf(DetailField field) + { + if (field == null || !DetailComposer.TryGetLexicalRelationOwner(field.StableId, out var hvo)) + return null; + return Cache.ServiceLocator.ObjectRepository.TryGetObject(hvo, out var owner) ? owner : null; + } + + /// + /// Runs Delete Relation for the row's relation: after confirmation with the note on + /// what else it affects, the entry or sense leaves the relation or the relation goes + /// (), in one undoable step, and the + /// re-show focuses the nearest surviving row. + /// + /// True when the relation changed. + internal bool DeleteRelation(DetailField field, ILexReference relation) + { + var current = LexicalRelationOwnerOf(field); + if (current == null) + { + Logger.WriteEvent("Delete Relation did nothing: row " + field?.StableId + " names no entry or sense."); + return false; + } + var focusRows = RowsToFocusAfterDeleting(m_avaloniaEntryForm?.ShownDetail, relation); + bool confirmed; + using (var ui = CmObjectUi.MakeUi(Cache, relation.Hvo)) + { + ui.Mediator = m_mediator; + ui.PropTable = m_propertyTable; + confirmed = RelationDeleteConfirmation(ui, LexicalRelationEditing.DeleteNote(relation, current)); + } + if (!confirmed) + return false; + LexicalRelationEditing.Delete(relation, current); + if (focusRows.Count > 0) + m_avaloniaEntryForm?.FocusRowOnNextShow(focusRows); + OnAvaloniaDetailEditCompleted(this, EventArgs.Empty); + return true; + } + + /// + /// The Add Reference / Replace Reference item for a lexical-relation row: absent + /// unless the row's object is a relation of a known entry or sense; enabled then. + /// + internal DetailMenuItem AddReferenceItem(DetailMenuLeaf leaf, DetailField field) + { + if (!(RowObjectOf(field) is ILexReference) || LexicalRelationOwnerOf(field) == null) + return null; + return new DetailMenuItem(XCoreMenuBridge.StripAccelerator(leaf.Label), isEnabled: true, isChecked: false, + children: null, execute: () => AddOrReplaceReference(field)); + } + + /// + /// Adds the entry or sense the user picks through + /// to the row's relation, or puts it in place of the row's reference + /// (): pending edits settle first, + /// the change is one undoable step, and the re-show focuses the relation's row. + /// + /// The relation the row's entry or sense is now in; null when nothing + /// changed. + internal ILexReference AddOrReplaceReference(DetailField field) + { + SettleDetailEdits(); + var relation = RowObjectOf(field) as ILexReference; + var current = LexicalRelationOwnerOf(field); + if (relation == null || current == null) + { + Logger.WriteEvent("Add Reference did nothing: row " + field?.StableId + " names no relation of an entry or sense."); + return null; + } + var chooser = LexicalRelationEditing.Chooser(relation, current); + var chosen = (RelationTargetChosen ?? ChooseRelationTargetWithDialog)(chooser); + if (chosen == null) + return null; + var result = LexicalRelationEditing.AddOrReplace(relation, current, chosen); + if (result == null) + return null; + m_avaloniaEntryForm?.FocusObjectOnNextShow(result.Hvo); + OnAvaloniaDetailEditCompleted(this, EventArgs.Empty); + return result; + } + + private ICmObject ChooseRelationTargetWithDialog(LexicalRelationChooser chooser) + { + var mainWindow = m_propertyTable.GetValue("window"); + if (chooser.UseLinkDialog) + { + return LcmLinkEntryOrSenseDialogLauncher.Show(Cache, m_mediator, m_propertyTable, null, mainWindow, + LexicalRelationEditing.ChooserHelpTopic, m_propertyTable.GetValue("HelpTopicProvider", null), + allowSenses: chooser.AllowSenses, sensesOnly: chooser.SensesOnly, title: chooser.Title, + okButtonText: chooser.ButtonText); + } + using (var dlg = new EntryGoDlg()) + { + dlg.SetDlgInfo(Cache, new WindowParams { m_title = chooser.Title, m_btnText = chooser.ButtonText }, m_mediator, + m_propertyTable); + dlg.SetHelpTopic(LexicalRelationEditing.ChooserHelpTopic); + return dlg.ShowDialog(mainWindow) == DialogResult.OK ? dlg.SelectedObject : null; + } + } + + /// + /// The Edit Reference Set Details item for a lexical-relation row: absent unless the + /// row's object is a relation; enabled then. + /// + internal DetailMenuItem EditReferenceItem(DetailMenuLeaf leaf, DetailField field) + { + if (!(RowObjectOf(field) is ILexReference)) + return null; + return new DetailMenuItem(XCoreMenuBridge.StripAccelerator(leaf.Label), isEnabled: true, isChecked: false, + children: null, execute: () => EditReferenceDetails(field)); + } + + /// + /// Gives the row's relation the name and comment the user enters through + /// : pending edits settle first, the change is one + /// undoable step, and the view re-shows. + /// + /// True when the relation changed. + internal bool EditReferenceDetails(DetailField field) + { + SettleDetailEdits(); + if (!(RowObjectOf(field) is ILexReference relation)) + { + Logger.WriteEvent("Edit Reference Set Details did nothing: the row's object " + field?.ObjectHvo + " is no relation."); + return false; + } + var details = (RelationDetailsEdited ?? EditRelationDetailsWithDialog)(relation); + if (details == null) + return false; + LexicalRelationEditing.SetDetails(relation, details.Name, details.Comment); + OnAvaloniaDetailEditCompleted(this, EventArgs.Empty); + return true; + } + + private RelationDetails EditRelationDetailsWithDialog(ILexReference relation) + { + using (var dlg = new LexReferenceDetailsDlg(m_propertyTable.GetValue("HelpTopicProvider"))) + { + dlg.ReferenceName = relation.Name.AnalysisDefaultWritingSystem.Text; + dlg.ReferenceComment = relation.Comment.AnalysisDefaultWritingSystem.Text; + return dlg.ShowDialog(m_propertyTable.GetValue("window")) == DialogResult.OK + ? new RelationDetails { Name = dlg.ReferenceName, Comment = dlg.ReferenceComment } + : null; + } + } + + /// + /// The Swap Allomorph with Lexeme Form item for the row: absent unless the row's + /// object is a form; enabled when it is an allomorph of an entry that has a lexeme + /// form, disabled otherwise. + /// + internal DetailMenuItem SwapAllomorphItem(DetailMenuLeaf leaf, DetailField field) + { + var form = FormOf(field); + if (form == null) + return null; + var label = XCoreMenuBridge.StripAccelerator(leaf.Label); + if (!CanSwapAllomorph(form)) + return DetailMenuItem.Disabled(label); + return new DetailMenuItem(label, isEnabled: true, isChecked: false, children: null, + execute: () => SwapAllomorph(field, leaf.Command)); + } + + // Whether the form is an allomorph of an entry with a lexeme form to swap it with. + private static bool CanSwapAllomorph(IMoForm form) + { + var entry = form.OwnerOfClass(); + return LexemeFormConversion.CanSwap(entry) && entry.AlternateFormsOS.Contains(form); + } + + /// + /// Makes the row's allomorph its entry's lexeme form, the old lexeme form taking its + /// place: pending edits settle first, the swap is one undoable step, and the re-show + /// focuses the form's new row. + /// + /// True when the forms were swapped. + internal bool SwapAllomorph(DetailField field, Command command) + { + SettleDetailEdits(); + var form = FormOf(field); + if (form == null || !CanSwapAllomorph(form)) + return false; + LexemeFormConversion.Swap(form.OwnerOfClass(), form, command.UndoText, command.RedoText); + m_avaloniaEntryForm?.FocusObjectOnNextShow(form.Hvo); + OnAvaloniaDetailEditCompleted(this, EventArgs.Empty); + return true; + } } } diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs index c4a6ec0e21..04e1ad55a2 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs @@ -55,6 +55,10 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase // pronunciation and Variant Info; the picture menu, no Avalonia row yet (LT-22772). "mnuDataTree-Etymology", "mnuDataTree-ExtendedNote", "mnuDataTree-ExtendedNote-Examples", "mnuDataTree-Example", "mnuDataTree-Pronunciation", "mnuDataTree-VariantSpec", "mnuDataTree-Picture", + // Lexicon rows: the item menus of a stem or affix allomorph and of an affix process, + // a variant's form row, and the lexical-relation rows (add or replace a reference). + "mnuDataTree-Allomorph", "mnuDataTree-AffixProcess", "mnuDataTree-VariantForm", + "mnuDataTree-DeleteAddLexReference", "mnuDataTree-DeleteReplaceLexReference", // Lexicon: an id no part reaches (its pronunciation part does not exist). "mnuDataTree-CmMedia", // Grammar: posEdit rows (two item rows, five section rows: Affix Templates, Affix @@ -82,7 +86,8 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase "mnuDataTree-Subsenses", "mnuDataTree-AlternateForms", "mnuDataTree-VariantForms", "mnuDataTree-Examples", "mnuDataTree-Etymology", "mnuDataTree-ExtendedNote", "mnuDataTree-ExtendedNote-Examples", "mnuDataTree-Example", "mnuDataTree-Pronunciation", - "mnuDataTree-VariantSpec" + "mnuDataTree-VariantSpec", "mnuDataTree-Allomorph", "mnuDataTree-AffixProcess", "mnuDataTree-VariantForm", + "mnuDataTree-DeleteAddLexReference", "mnuDataTree-DeleteReplaceLexReference" }; private static readonly string[] LexiconInStringMenuIds = @@ -107,7 +112,11 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase private List m_createdObjects; private ILexEntry m_entry; private IMoStemAllomorph m_allomorph; + private IMoAffixProcess m_affixProcess; private ILexEntry m_variant; + private ILexReference m_synonymSet; + private ILexReference m_antonymPair; + private ILexReference m_partOfWhole; private ICmTranslation m_translation; private ILexExtendedNote m_note; private ILexExtendedNote m_note2; @@ -317,8 +326,8 @@ public void OwnedMenus_AreBuiltWithoutTheAdapterOrTheMediator() { Assert.That(HiddenTreeExists, Is.False, "precondition: no hidden tree yet"); var rows = OwnedMenuRows(); - Assert.That(rows, Has.Count.EqualTo(35), - "precondition: twenty-one ids; -Translation and -VariantForms on two rows each, the item ids and hotlinks on every item's rows"); + Assert.That(rows, Has.Count.EqualTo(41), + "precondition: twenty-six ids; -Translation, -VariantForms and -DeleteReplaceLexReference on two rows each, the item ids and hotlinks on every item's rows"); foreach (var (row, menuId, kind) in rows) { var ids = kind == DetailMenuKind.ContextMenu @@ -363,12 +372,14 @@ public void DeleteLeaf_IsDisabled_WhenTheObjectIsGone_AndWhenTheRuleRefuses() var gone = new DetailField(translation.StableId, "Translation", "Translation", null, DetailFieldKind.Text, EditorClassification.Known, null, null, HostRouting.Inherit, null, null, null, menuId: translation.MenuId, objectHvo: int.MaxValue - 1); - // A sense of an entry without entry refs sits in a required field: the only one - // stays. The sense's own Delete command names its class. + // An entry without entry refs requires its senses, so the only one stays. The entry + // is shown, so its sense is no other entry's part (a variant back reference). ILexEntry plain = null; NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => plain = AddLexeme(m_createdObjects, "plain", GetMorphTypeOrCreateOne("stem"), "plain gloss", GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA))); + m_view.Clerk.JumpToRecord(plain.Hvo); + DrainMediatorAndIdleQueues(); var onlySense = new DetailField("Sense", "Sense", "Senses", null, DetailFieldKind.Header, EditorClassification.GroupingNone, null, null, HostRouting.Inherit, null, null, null, menuId: "mnuDataTree-Sense", objectHvo: plain.SensesOS[0].Hvo); @@ -663,8 +674,98 @@ public void FindExampleSentence_IsOfferedOnARowWithAnObject() Assert.That(m_view.GuiControlItem(leaf, noObject), Is.Null, "no object"); } + [Test] + public void MergeLeaf_IsEnabledWithACandidate_DisabledWithTheNoteWithout_AndAbsentOnAGhostRow() + { + var leaf = Leaf("mnuDataTree-Allomorph", "CmdDataTree-Merge-Allomorph"); + var stem = FormRow(m_allomorph); + var process = FormRow(m_affixProcess); + var ghost = new DetailField(stem.StableId + DetailField.GhostStableIdSuffix, "Form", "Form", null, + DetailFieldKind.Text, EditorClassification.Known, null, null, HostRouting.Inherit, null, null, null, + menuId: stem.MenuId, objectHvo: stem.ObjectHvo, ghostPrompt: "Add a form"); + + Assert.That(m_view.MergeItem(leaf, stem)?.IsEnabled, Is.True, "the lexeme form is of the stem's class"); + Assert.That(m_view.MergeItem(leaf, stem).Label, Is.EqualTo("Merge Allomorph into...")); + Assert.That(m_view.MergeItem(leaf, process)?.IsEnabled, Is.False, "nothing of the process's class to merge into"); + Assert.That(m_view.MergeItem(leaf, process).Label, + Is.EqualTo("Merge Allomorph into..." + StringTable.Table.GetString(DetailObjectMerge.CannotMergeKey))); + Assert.That(m_view.MergeItem(leaf, ghost), Is.Null, "a ghost row has no object to merge"); + Assert.That(m_view.MergeItem(leaf, null), Is.Null, "no row"); + } + + // A variant's form row shows the variant's lexeme form, but Delete Variant and Delete + // Reference act on the variant entry and its ref to the shown entry. + [Test] + public void VariantFormRow_DeleteActsOnTheVariantEntry_AndDeleteReferenceNeedsItsRef() + { + var row = VariantFormRow(); + var deleteVariant = Leaf("mnuDataTree-VariantForm", "CmdDataTree-Delete-Variant"); + var deleteReference = Leaf("mnuDataTree-VariantForm", "CmdDataTree-Delete-VariantReference"); + var note = StringTable.Table.GetString(DetailObjectDeletion.CannotDeleteKey); + + Assert.That(m_view.DeleteItem(deleteVariant, row)?.IsEnabled, Is.True, "an entry is unowned, so it can go"); + Assert.That(m_view.DeleteItem(deleteVariant, row).Label, Is.EqualTo("Delete Variant")); + Assert.That(m_view.DeleteReferenceItem(deleteReference, row)?.IsEnabled, Is.True); + Assert.That(m_view.DeleteReferenceItem(deleteReference, row).Label, Is.EqualTo("Delete Reference")); + + var allomorph = FormRow(m_allomorph); + Assert.That(m_view.DeleteItem(deleteVariant, allomorph), Is.Null, "the shown entry's own form is no LexEntry"); + Assert.That(m_view.DeleteReferenceItem(deleteReference, allomorph)?.IsEnabled, Is.False, "no variant, no ref"); + Assert.That(m_view.DeleteReferenceItem(deleteReference, allomorph).Label, Is.EqualTo("Delete Reference" + note)); + var ghost = new DetailField(row.StableId + DetailField.GhostStableIdSuffix, "Variant Form", "Form", null, + DetailFieldKind.Text, EditorClassification.Known, null, null, HostRouting.Inherit, null, null, null, + menuId: row.MenuId, objectHvo: m_variant.Hvo, ghostPrompt: "Add a form"); + Assert.That(m_view.DeleteReferenceItem(deleteReference, ghost), Is.Null, "a ghost row offers no delete"); + } + + [Test] + public void SwapAllomorphLeaf_IsEnabledOnAnAllomorph_DisabledWithoutALexemeForm_AndAbsentOffAForm() + { + var leaf = Leaf("mnuDataTree-Allomorph", "CmdDataTree-Swap-Allomorph"); + var stem = FormRow(m_allomorph); + + Assert.That(m_view.SwapAllomorphItem(leaf, stem)?.IsEnabled, Is.True); + Assert.That(m_view.SwapAllomorphItem(leaf, TranslationRow()), Is.Null, "no form on the row"); + Assert.That(m_view.SwapAllomorphItem(leaf, null), Is.Null, "no row"); + + // The fixture is rebuilt per test, so the lexeme form need not come back. + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => m_entry.LexemeFormOA = null); + Assert.That(m_view.SwapAllomorphItem(leaf, stem)?.IsEnabled, Is.False, "nothing to swap with"); + } + + [Test] + public void RelationLeaves_AreEnabledOnARelationRow_AndAbsentElsewhere() + { + var add = Leaf("mnuDataTree-DeleteAddLexReference", "CmdDataTree-Add-ToLexReference"); + var replace = Leaf("mnuDataTree-DeleteReplaceLexReference", "CmdDataTree-Replace-LexReference"); + var edit = Leaf("mnuDataTree-DeleteAddLexReference", "CmdDataTree-EditDetails-LexReference"); + var delete = Leaf("mnuDataTree-DeleteAddLexReference", "CmdDataTree-Delete-LexReference"); + var synonyms = RelationRow(m_synonymSet); + var antonym = RelationRow(m_antonymPair); + Assert.That(synonyms.MenuId, Is.EqualTo("mnuDataTree-DeleteAddLexReference"), "precondition"); + Assert.That(antonym.MenuId, Is.EqualTo("mnuDataTree-DeleteReplaceLexReference"), "precondition"); + Assert.That(RelationRow(m_partOfWhole).MenuId, Is.EqualTo("mnuDataTree-DeleteReplaceLexReference"), + "precondition: a part sees its whole"); + + Assert.That(m_view.AddReferenceItem(add, synonyms)?.IsEnabled, Is.True); + Assert.That(m_view.AddReferenceItem(replace, antonym)?.IsEnabled, Is.True); + Assert.That(m_view.EditReferenceItem(edit, synonyms)?.IsEnabled, Is.True); + Assert.That(m_view.DeleteItem(delete, synonyms)?.IsEnabled, Is.True); + Assert.That(m_view.DeleteItem(delete, synonyms).Label, Is.EqualTo("Delete Relation")); + Assert.That(m_view.AddReferenceItem(add, TranslationRow()), Is.Null, "no relation on the row"); + Assert.That(m_view.EditReferenceItem(edit, TranslationRow()), Is.Null); + Assert.That(m_view.AddReferenceItem(add, null), Is.Null, "no row"); + } + private XWindow Window => m_propertyTable.GetValue("window"); + private DetailField FormRow(IMoForm form) => Rows().Single(f => f.ObjectHvo == form.Hvo && f.Field == "Form"); + + private DetailField VariantFormRow() + => Rows().Single(f => f.MenuId == "mnuDataTree-VariantForm" && f.ObjectHvo == m_variant.LexemeFormOA.Hvo); + + private DetailField RelationRow(ILexReference relation) => Rows().Single(f => f.ObjectHvo == relation.Hvo); + private bool HiddenTreeExists => (bool)GetField(m_view, "m_dataTreeInitialized"); private LegacyDataTree AdapterTree => (LegacyDataTree)GetField(m_view, "m_dataEntryForm"); @@ -815,6 +916,36 @@ public bool OnDisplayPictureProperties(object commandObject, ref UIItemDisplayPr Asked = true; return false; } + public bool OnDisplayDataTreeMerge(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } + public bool OnDisplayDataTreeDeleteReference(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } + public bool OnDisplayDataTreeAddReference(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } + public bool OnDisplayDataTreeEdit(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } + public bool OnDisplaySwapAllomorphWithLexeme(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } + public bool OnDisplayConvertAllomorph(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } } private static string Describe(IReadOnlyList items, string indent = "") @@ -843,9 +974,9 @@ private void EnsureAdapter(int targetHvo, string fieldName) method.Invoke(m_view, new object[] { targetHvo, fieldName }); } - // A complex form and variant with an allomorph, a spelling variant, two etymologies and - // two pronunciations; its sense has a translated example, two pictures and two notes, - // the first with an example. + // A complex form and variant: two allomorphs, a spelling variant, two etymologies, + // two pronunciations, an antonym and a whole; its sense has an example, pictures, + // notes and synonyms. private void CreateTestEntry() { var stemMorphType = GetMorphTypeOrCreateOne("stem"); @@ -877,6 +1008,8 @@ private void CreateTestEntry() m_allomorph = Cache.ServiceLocator.GetInstance().Create(); m_entry.AlternateFormsOS.Add(m_allomorph); m_allomorph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("command-allomorph", Cache.DefaultVernWs)); + m_affixProcess = Cache.ServiceLocator.GetInstance().Create(); + m_entry.AlternateFormsOS.Add(m_affixProcess); var example = Cache.ServiceLocator.GetInstance().Create(); m_entry.SensesOS[0].ExamplesOS.Add(example); example.Example.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("una casa", Cache.DefaultVernWs)); @@ -908,6 +1041,28 @@ private void CreateTestEntry() variantRef.ComponentLexemesRS.Add(m_entry); variantRef.VariantEntryTypesRS.Add(Cache.ServiceLocator.GetInstance() .GetObject(LexEntryTypeTags.kguidLexTypSpellingVar)); + + if (Cache.LangProject.LexDbOA.ReferencesOA == null) + Cache.LangProject.LexDbOA.ReferencesOA = Cache.ServiceLocator.GetInstance().Create(); + m_synonymSet = AddRelation("Synonyms", LexRefTypeTags.MappingTypes.kmtSenseCollection, + m_entry.SensesOS[0], component.SensesOS[0], baseEntry.SensesOS[0]); + m_antonymPair = AddRelation("Antonym", LexRefTypeTags.MappingTypes.kmtEntryPair, m_entry, baseEntry); + m_partOfWhole = AddRelation("Whole", LexRefTypeTags.MappingTypes.kmtEntryTree, component, m_entry); + } + + // A relation of a new type of the given mapping, over the targets in that order. + private ILexReference AddRelation(string typeName, LexRefTypeTags.MappingTypes mapping, params ICmObject[] targets) + { + var type = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.LexDbOA.ReferencesOA.PossibilitiesOS.Add(type); + m_createdObjects.Add(type); + type.MappingType = (int)mapping; + type.Name.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString(typeName, Cache.DefaultAnalWs)); + var relation = Cache.ServiceLocator.GetInstance().Create(); + type.MembersOC.Add(relation); + foreach (var target in targets) + relation.TargetsRS.Add(target); + return relation; } private void LoadRecordEditView(string toolValue) diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs index ffd437167e..bfd0626b60 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs @@ -10,9 +10,11 @@ using System.Xml; using NUnit.Framework; using SIL.FieldWorks.Common.Controls; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; using SIL.FieldWorks.Common.FwUtils; +using SIL.FieldWorks.FdoUi; using SIL.LCModel; using SIL.LCModel.Core.Text; using SIL.LCModel.DomainServices; @@ -32,6 +34,7 @@ namespace SIL.FieldWorks.XWorks public class LexiconMenuExecutionTests : XWorksAppTestBase { private const string LexemeFormMenuId = "mnuDataTree-LexemeForm"; + private const string VariantFormMenuId = "mnuDataTree-VariantForm"; private const string VariantFormContextMenuId = "mnuDataTree-VariantFormContext"; private PropertyTable m_propertyTable; @@ -42,6 +45,12 @@ public class LexiconMenuExecutionTests : XWorksAppTestBase private IMoStemAllomorph m_allomorph; private ILexEntry m_variant; private ILexEntry m_affix; + private IMoAffixAllomorph m_affixAllomorph; + private ILexEntry m_component; + private ILexEntry m_spare; + private ILexReference m_synonymSet; + private ILexReference m_antonymPair; + private ILexReference m_partOfWhole; private SIL.LCModel.IText m_text; private ILexExampleSentence m_example; private ICmTranslation m_translation; @@ -430,8 +439,8 @@ public void InsertAffixProcess_OnAnAffixEntry_AddsAProcess() InvokeLabelMenuItem(SectionRow(m_affix, "mnuDataTree-AlternateForms"), "Insert Affix Process"); - Assert.That(m_affix.AlternateFormsOS, Has.Count.EqualTo(1)); - Assert.That(m_affix.AlternateFormsOS[0], Is.InstanceOf()); + Assert.That(m_affix.AlternateFormsOS, Has.Count.EqualTo(2), "after the fixture's affix allomorph"); + Assert.That(m_affix.AlternateFormsOS[1], Is.InstanceOf()); } [Test] @@ -602,6 +611,251 @@ public void PictureProperties_PointsThePictureAtTheFileTheUserPicks_InOneUndoabl Assert.That(m_picture.PictureFileRA.InternalPath, Does.EndWith("command-picture.jpg"), "one Undo restores the file"); } + [Test] + public void MergeAllomorph_IntoThePickedSurvivor_InOneUndoableStep() + { + var lexemeForm = m_entry.LexemeFormOA; + IReadOnlyList offered = null; + m_view.MergeSurvivor = candidates => + { + offered = candidates; + return lexemeForm.Hvo; + }; + + InvokeLabelMenuItem(FormRow(m_entry, m_allomorph), "Merge Allomorph into..."); + + Assert.That(offered.Select(c => c.Hvo), Is.EqualTo(new[] { lexemeForm.Hvo }), "the lexeme form is the one candidate"); + Assert.That(m_allomorph.IsValidObject, Is.False, "the allomorph merged away"); + Assert.That(m_entry.AlternateFormsOS, Is.Empty); + Assert.That(m_entry.LexemeFormOA, Is.SameAs(lexemeForm)); + Assert.That(Cache.ActionHandlerAccessor.CanUndo(), Is.True); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_entry.AlternateFormsOS, Has.Count.EqualTo(1), "one Undo brings the allomorph back"); + } + + [Test] + public void MergeAllomorph_LeavesTheModelAlone_WhenTheUserCancels() + { + m_view.MergeSurvivor = candidates => 0; + + InvokeLabelMenuItem(FormRow(m_entry, m_allomorph), "Merge Allomorph into..."); + + Assert.That(m_allomorph.IsValidObject, Is.True); + Assert.That(m_entry.AlternateFormsOS, Is.EqualTo(new IMoForm[] { m_allomorph })); + } + + [Test] + public void DeleteVariant_DeletesTheVariantEntry_WhenTheUserConfirms() + { + var row = VariantFormRow(); + ICmObject asked = null; + m_view.DeleteConfirmation = ui => + { + asked = ui.Object; + return true; + }; + + InvokeLabelMenuItem(row, "Delete Variant"); + + Assert.That(asked, Is.SameAs(m_variant), "the confirmation names the variant entry, not its form"); + Assert.That(m_variant.IsValidObject, Is.False); + Assert.That(Rows(m_entry).Any(f => f.MenuId == VariantFormMenuId), Is.False, "the recomposed view has no variant row"); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_entry.VariantFormEntryBackRefs.Count(), Is.EqualTo(1), "one Undo brings the variant back"); + } + + [Test] + public void DeleteReference_UnlinksTheVariant_AndKeepsTheEntry() + { + var row = VariantFormRow(); + var reference = m_variant.EntryRefsOS[0]; + + InvokeLabelMenuItem(row, "Delete Reference"); + + Assert.That(m_variant.IsValidObject, Is.True, "the variant entry stays"); + Assert.That(reference.IsValidObject, Is.False, "its ref to the shown entry is gone"); + Assert.That(m_variant.EntryRefsOS, Is.Empty); + Assert.That(Rows(m_entry).Any(f => f.MenuId == VariantFormMenuId), Is.False, "the recomposed view has no variant row"); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("Undo Delete Reference")); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_variant.EntryRefsOS[0].ComponentLexemesRS.ToList(), Is.EqualTo(new[] { m_entry })); + } + + // A variant linked twice to the shown entry shows two groups of rows; the ref deleted + // is the one whose Variant Info rows follow the clicked Form row. + [Test] + public void DeleteReference_OnAVariantLinkedTwice_RemovesTheRefOfTheClickedGroup() + { + var first = m_variant.EntryRefsOS[0]; + ILexEntryRef second = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + second = Cache.ServiceLocator.GetInstance().Create(); + m_variant.EntryRefsOS.Add(second); + second.RefType = LexEntryRefTags.krtVariant; + second.ComponentLexemesRS.Add(m_entry); + second.VariantEntryTypesRS.Add(Cache.ServiceLocator.GetInstance() + .GetObject(LexEntryTypeTags.kguidLexTypSpellingVar)); + }); + ShowEntry(m_entry); + // Both Form rows share one id, so the clicked row must be the shown model's own. + var rows = ShownRows(); + var formRows = rows.Where(f => f.MenuId == VariantFormMenuId && f.ObjectHvo == m_variant.LexemeFormOA.Hvo).ToList(); + Assert.That(formRows, Has.Count.EqualTo(2), "precondition: one Form row per ref"); + var clicked = formRows[1]; + var expected = rows.Skip(rows.IndexOf(clicked) + 1) + .Select(f => f.ObjectHvo == first.Hvo ? first : f.ObjectHvo == second.Hvo ? second : null) + .First(r => r != null); + + InvokeLabelMenuItem(clicked, "Delete Reference"); + + Assert.That(expected.IsValidObject, Is.False, "the clicked group's ref is gone"); + Assert.That(m_variant.EntryRefsOS, Has.Count.EqualTo(1), "the other ref stays"); + } + + [Test] + public void SwapAllomorph_MakesTheRowsFormTheLexemeForm_InOneUndoableStep() + { + var oldLexemeForm = m_entry.LexemeFormOA; + + InvokeLabelMenuItem(FormRow(m_entry, m_allomorph), "Swap Allomorph with Lexeme Form"); + + Assert.That(m_entry.LexemeFormOA, Is.SameAs(m_allomorph)); + Assert.That(m_entry.AlternateFormsOS, Is.EqualTo(new IMoForm[] { oldLexemeForm })); + Assert.That(LexemeFormRow(m_entry).ObjectHvo, Is.EqualTo(m_allomorph.Hvo), "the recomposed Lexeme Form row shows it"); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_entry.LexemeFormOA, Is.SameAs(oldLexemeForm), "one Undo swaps them back"); + } + + [Test] + public void ConvertAllomorph_ReplacesTheRowsForm_AndFocusesTheNewOne() + { + ShowEntry(m_affix); + m_view.ConversionLossAccepted = warning => throw new InvalidOperationException("nothing is lost: no question"); + + InvokeLabelMenuItem(FormRow(m_affix, m_affixAllomorph), "Convert to Affix Process"); + + Assert.That(m_affix.AlternateFormsOS, Has.Count.EqualTo(1)); + var converted = m_affix.AlternateFormsOS[0]; + Assert.That(converted, Is.InstanceOf()); + Assert.That(converted.Form.get_String(Cache.DefaultVernWs).Text, Is.EqualTo("in"), "the form text carries over"); + Assert.That(m_affix.LexemeFormOA, Is.InstanceOf(), "the lexeme form is untouched"); + Assert.That(Rows(m_affix).Single(f => f.ObjectHvo == converted.Hvo && f.Field == "Form").MenuId, + Is.EqualTo("mnuDataTree-AffixProcess"), "the recomposed row binds the process menu"); + } + + [Test] + public void DeleteRelation_RemovesTheSenseFromItsSynonyms_WithTheNoteOnTheOthers() + { + string note = null; + m_view.RelationDeleteConfirmation = (ui, tss) => + { + Assert.That(ui.Object, Is.SameAs(m_synonymSet), "the confirmation names the relation"); + note = tss?.Text; + return true; + }; + + InvokeLabelMenuItem(RelationRow(m_synonymSet), "Delete Relation"); + + Assert.That(note, Does.Contain("Synonyms"), "the other two stay related"); + Assert.That(m_synonymSet.IsValidObject, Is.True); + Assert.That(m_synonymSet.TargetsRS, Has.Count.EqualTo(2)); + Assert.That(m_synonymSet.TargetsRS.Contains(m_sense1), Is.False); + Assert.That(Rows(m_entry).Any(f => f.ObjectHvo == m_synonymSet.Hvo), Is.False, "the recomposed view has no row for it"); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("Undo Delete Relation")); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_synonymSet.TargetsRS.Contains(m_sense1), Is.True); + } + + [Test] + public void DeleteRelation_DeletesAnAntonymPair_AndDoesNothingWhenDeclined() + { + var notes = new List(); + m_view.RelationDeleteConfirmation = (ui, tss) => + { + notes.Add(tss?.Text); + return false; + }; + InvokeLabelMenuItem(RelationRow(m_antonymPair), "Delete Relation"); + Assert.That(m_antonymPair.IsValidObject, Is.True, "declined"); + Assert.That(notes, Is.EqualTo(new string[] { null }), "a pair adds no note"); + + m_view.RelationDeleteConfirmation = (ui, tss) => true; + InvokeLabelMenuItem(RelationRow(m_antonymPair), "Delete Relation"); + Assert.That(m_antonymPair.IsValidObject, Is.False, "a pair goes whole"); + } + + [Test] + public void AddReference_AddsTheChosenSense_ThroughTheSenseChooser() + { + LexicalRelationChooser asked = null; + var chosen = m_spare.SensesOS[0]; + m_view.RelationTargetChosen = chooser => + { + asked = chooser; + return chosen; + }; + + InvokeLabelMenuItem(RelationRow(m_synonymSet), "Add Reference"); + + Assert.That(asked.UseLinkDialog && asked.SensesOnly, Is.True, "a sense collection picks among senses"); + Assert.That(asked.IsReplace, Is.False); + Assert.That(m_synonymSet.TargetsRS.Last(), Is.SameAs(chosen)); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("Undo Add Reference")); + } + + [Test] + public void ReplaceReference_OnAPair_ReplacesThePartner_AndOnAPart_MovesItUnderTheChosenWhole() + { + m_view.RelationTargetChosen = chooser => + { + Assert.That(chooser.IsReplace, Is.True); + Assert.That(chooser.UseLinkDialog, Is.False, "an entry type picks among entries"); + return m_spare; + }; + + InvokeLabelMenuItem(RelationRow(m_antonymPair), "Replace Reference"); + Assert.That(m_antonymPair.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { m_entry, m_spare })); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("Undo Replace Reference")); + + InvokeLabelMenuItem(RelationRow(m_partOfWhole), "Replace Reference"); + Assert.That(m_partOfWhole.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { m_spare, m_entry }), + "the only part keeps its relation, now under the chosen whole"); + Assert.That(Rows(m_entry).Single(f => f.ObjectHvo == m_partOfWhole.Hvo).Items.Single().Name, + Does.Contain("spare"), "the recomposed row shows the new whole"); + } + + // The host composes with the tool's slice filter and the view overrides; the relation + // rows must survive that path too, not only a bare compose. + [Test] + public void RelationRows_AreShownByTheHost_OnTheEntryAndItsSense() + { + var shown = ShownRows(); + Assert.That(shown.Any(f => f.ObjectHvo == m_antonymPair.Hvo), Is.True, "the entry's pair row"); + Assert.That(shown.Any(f => f.ObjectHvo == m_partOfWhole.Hvo), Is.True, "the entry's whole row"); + Assert.That(shown.Any(f => f.ObjectHvo == m_synonymSet.Hvo), Is.True, "the sense's synonyms row"); + } + + [Test] + public void EditReferenceDetails_SetsTheNameAndComment_InOneUndoableStep() + { + m_view.RelationDetailsEdited = relation => + { + Assert.That(relation, Is.SameAs(m_synonymSet)); + return new RecordEditView.RelationDetails { Name = "body parts", Comment = "the usual" }; + }; + + InvokeLabelMenuItem(RelationRow(m_synonymSet), "Edit Reference Set Details"); + + Assert.That(m_synonymSet.Name.AnalysisDefaultWritingSystem.Text, Is.EqualTo("body parts")); + Assert.That(m_synonymSet.Comment.AnalysisDefaultWritingSystem.Text, Is.EqualTo("the usual")); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("Undo Edit Reference Set Details")); + + m_view.RelationDetailsEdited = relation => null; + InvokeLabelMenuItem(RelationRow(m_synonymSet), "Edit Reference Set Details"); + Assert.That(m_synonymSet.Name.AnalysisDefaultWritingSystem.Text, Is.EqualTo("body parts"), "cancelled: unchanged"); + } + // Builds the row's label menu as the host does and runs the named item's own Execute. private void InvokeLabelMenuItem(DetailField row, string label) => Invoke(RecordEditView.ComposeSliceMenuIds(row.MenuId, row.IsMultiStringRow).ToArray(), row, @@ -648,11 +902,30 @@ private void ShowEntry(ILexEntry entry) private IReadOnlyList Rows(ILexEntry entry) => DetailComposer.Compose(entry, Cache).Model.Fields; + // The rows of the model the host shows now, the instances its menus are asked about. + private List ShownRows() + { + var hostField = typeof(RecordEditView).GetField("m_avaloniaEntryForm", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(hostField, Is.Not.Null, "Missing private field: m_avaloniaEntryForm"); + var host = (SIL.FieldWorks.Common.FwAvalonia.DetailHostControl)hostField.GetValue(m_view); + return host.ShownDetail.Fields.ToList(); + } + private DetailField TranslationRow() => Rows(m_entry).Single(f => f.Field == "Translation" && f.ObjectHvo == m_translation.Hvo); private DetailField LexemeFormRow(ILexEntry entry) => Rows(entry).Single(f => f.MenuId == LexemeFormMenuId); + // The Form row of one of the entry's allomorphs. + private DetailField FormRow(ILexEntry entry, IMoForm form) + => Rows(entry).Single(f => f.ObjectHvo == form.Hvo && f.Field == "Form"); + + // The shown entry's row for its variant's form. + private DetailField VariantFormRow() + => Rows(m_entry).Single(f => f.MenuId == VariantFormMenuId && f.ObjectHvo == m_variant.LexemeFormOA.Hvo); + + private DetailField RelationRow(ILexReference relation) => Rows(m_entry).Single(f => f.ObjectHvo == relation.Hvo); + // The entry's row binding the given section menu. private DetailField SectionRow(ILexEntry entry, string menuId) => Rows(entry).Single(f => f.MenuId == menuId && f.ObjectHvo == entry.Hvo); @@ -664,9 +937,9 @@ private DetailField VariantsRow() private DetailField HeaderRowOf(ICmObject obj) => Rows(m_entry).First(f => f.ObjectHvo == obj.Hvo && f.Kind == DetailFieldKind.Header && f.HotlinksId != null); - // A complex form that is also two variants, with an allomorph, two etymologies, two - // pronunciations and two senses, the first with a translated example, two notes and a - // picture; a suffix; a text. + // A complex form and double variant with an allomorph, an antonym, a whole and two + // senses (the first with example, notes, picture, synonyms); a suffix with an + // allomorph; a spare entry; a text. private void CreateTestEntry() { var stemMorphType = GetMorphTypeOrCreateOne("stem"); @@ -694,7 +967,7 @@ private void CreateTestEntry() m_translation = Cache.ServiceLocator.GetInstance().Create(m_example, type); m_translation.Translation.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString("a house", Cache.DefaultAnalWs)); - var component = AddLexeme(m_createdObjects, "part", stemMorphType, "part gloss", noun); + var component = m_component = AddLexeme(m_createdObjects, "part", stemMorphType, "part gloss", noun); m_complexFormRef = Cache.ServiceLocator.GetInstance().Create(); m_entry.EntryRefsOS.Add(m_complexFormRef); m_complexFormRef.RefType = LexEntryRefTags.krtComplexForm; @@ -735,9 +1008,35 @@ private void CreateTestEntry() m_affix = AddLexeme(m_createdObjects, "ing", GetMorphTypeOrCreateOne("suffix"), "suffix gloss", noun); Assert.That(m_affix.LexemeFormOA, Is.InstanceOf(), "precondition: a suffix entry has an affix form"); + m_affixAllomorph = Cache.ServiceLocator.GetInstance().Create(); + m_affix.AlternateFormsOS.Add(m_affixAllomorph); + m_affixAllomorph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("in", Cache.DefaultVernWs)); m_text = Cache.ServiceLocator.GetInstance().Create(); m_createdObjects.Add(m_text); + + m_spare = AddLexeme(m_createdObjects, "spare", stemMorphType, "spare gloss", noun); + if (Cache.LangProject.LexDbOA.ReferencesOA == null) + Cache.LangProject.LexDbOA.ReferencesOA = Cache.ServiceLocator.GetInstance().Create(); + m_synonymSet = AddRelation("Synonyms", LexRefTypeTags.MappingTypes.kmtSenseCollection, + m_sense1, component.SensesOS[0], m_variant.SensesOS[0]); + m_antonymPair = AddRelation("Antonym", LexRefTypeTags.MappingTypes.kmtEntryPair, m_entry, component); + m_partOfWhole = AddRelation("Whole", LexRefTypeTags.MappingTypes.kmtEntryTree, component, m_entry); + } + + // A relation of a new type of the given mapping, over the targets in that order. + private ILexReference AddRelation(string typeName, LexRefTypeTags.MappingTypes mapping, params ICmObject[] targets) + { + var type = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.LexDbOA.ReferencesOA.PossibilitiesOS.Add(type); + m_createdObjects.Add(type); + type.MappingType = (int)mapping; + type.Name.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString(typeName, Cache.DefaultAnalWs)); + var relation = Cache.ServiceLocator.GetInstance().Create(); + type.MembersOC.Add(relation); + foreach (var target in targets) + relation.TargetsRS.Add(target); + return relation; } private void LoadRecordEditView(string toolValue) From 8e32a0e0e2f378b9c9227fdb195645bb9ef62226 Mon Sep 17 00:00:00 2001 From: mark-sil <83427558+mark-sil@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:08:35 -0400 Subject: [PATCH 2/2] LT-22691: Leave a relation alone when its reference is chosen again Replace Reference now does nothing when the user picks the tree whole or pair partner the relation already gives the shown entry or sense. Until now the view still re-showed and moved the keyboard, though the relation did not change. Co-Authored-By: Claude Opus 5.5 --- Src/FdoUi/DetailRules/LexicalRelationEditing.cs | 11 +++++++++-- .../DetailRules/LexicalRelationEditingTests.cs | 6 +++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Src/FdoUi/DetailRules/LexicalRelationEditing.cs b/Src/FdoUi/DetailRules/LexicalRelationEditing.cs index 4bafcfde86..5678f82d67 100644 --- a/Src/FdoUi/DetailRules/LexicalRelationEditing.cs +++ b/Src/FdoUi/DetailRules/LexicalRelationEditing.cs @@ -311,7 +311,8 @@ public static ICmObject PairPartner(ILexReference relation, ICmObject current) /// Makes the reference for , in /// one undoable step: a pair's other member is replaced, a tree's part joins the /// relation whose whole is the choice, and any other relation gains the choice as a - /// member unless it is one already. + /// member. A choice the relation already gives changes + /// nothing. /// /// The relation is now in; null when nothing /// changed. @@ -327,13 +328,19 @@ public static ILexReference AddOrReplace(ILexReference relation, ICmObject curre ILexReference result = relation; if (IsPair(mapping)) { - var replace = PairPartner(relation, current) != null; + var partner = PairPartner(relation, current); + if (partner == chosen) + return null; + var replace = partner != null; UndoableUnitOfWorkHelper.Do(replace ? FdoUiStrings.ksUndoReplaceRef : FdoUiStrings.ksUndoAddRef, replace ? FdoUiStrings.ksRedoReplaceRef : FdoUiStrings.ksRedoAddRef, relation, () => ReplacePairPartner(relation, current, chosen)); } else if (IsTree(mapping) && !IsFirstTarget(relation, current)) { + // The part's own whole (LT-10987). + if (relation.TargetsRS[0] == chosen) + return null; UndoableUnitOfWorkHelper.Do(FdoUiStrings.ksUndoReplaceRef, FdoUiStrings.ksRedoReplaceRef, relation, () => result = ReplaceTreeWhole(relation, current, chosen)); } diff --git a/Src/FdoUi/FdoUiTests/DetailRules/LexicalRelationEditingTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/LexicalRelationEditingTests.cs index 6424b8dc8d..e43b06250c 100644 --- a/Src/FdoUi/FdoUiTests/DetailRules/LexicalRelationEditingTests.cs +++ b/Src/FdoUi/FdoUiTests/DetailRules/LexicalRelationEditingTests.cs @@ -173,6 +173,10 @@ public void AddOrReplace_ReplacesThePartnerOfAPair_InOneStep() Cache.ActionHandlerAccessor.Undo(); Assert.That(_antonymPair.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _face, _nose })); + + Assert.That(LexicalRelationEditing.AddOrReplace(_antonymPair, _nose, _face), Is.Null, + "choosing the current partner changes nothing"); + Assert.That(_antonymPair.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _face, _nose })); } [Test] @@ -198,7 +202,7 @@ public void ReplaceTreeWhole_RetargetsARelationWithOnePart_AndIgnoresTheSameWhol Assert.That(result, Is.SameAs(_faceParts), "the only part keeps its relation, retargeted (LT-14926)"); Assert.That(_faceParts.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _head, _nose })); - Assert.That(LexicalRelationEditing.AddOrReplace(_faceParts, _nose, _head), Is.SameAs(_faceParts), + Assert.That(LexicalRelationEditing.AddOrReplace(_faceParts, _nose, _head), Is.Null, "choosing the current whole changes nothing (LT-10987)"); Assert.That(_faceParts.TargetsRS.ToList(), Is.EqualTo(new ICmObject[] { _head, _nose })); }