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..5678f82d67 --- /dev/null +++ b/Src/FdoUi/DetailRules/LexicalRelationEditing.cs @@ -0,0 +1,416 @@ +// 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. A choice the relation already gives changes + /// nothing. + /// + /// 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 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)); + } + 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..e43b06250c --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/LexicalRelationEditingTests.cs @@ -0,0 +1,249 @@ +// 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 })); + + 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] + 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.Null, + "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)