diff --git a/Src/Common/Controls/DetailControls/DataTree.cs b/Src/Common/Controls/DetailControls/DataTree.cs index e9524259eb..c281a5935d 100644 --- a/Src/Common/Controls/DetailControls/DataTree.cs +++ b/Src/Common/Controls/DetailControls/DataTree.cs @@ -4631,119 +4631,21 @@ private void OnJumpToToolAndFilterAnthroItem(string toolToJumpTo) } /// - /// Common logic shared between OnDisplayJumpToTool and OnJumpToTool. - /// forEnableOnly is true when called from OnDisplayJumpToTool. + /// Common logic shared between OnDisplayJumpToTool and OnJumpToTool: the target + /// resolves for the current slice, or the root when + /// there is none. A real jump to the notebook first creates the record a text lacks. /// + /// True when called from OnDisplayJumpToTool. internal Guid GetGuidForJumpToTool(Command cmd, bool forEnableOnly, out string tool) { tool = XmlUtils.GetMandatoryAttributeValue(cmd.Parameters[0], "tool"); string className = XmlUtils.GetMandatoryAttributeValue(cmd.Parameters[0], "className"); - ICmObject targetObject; - if (CurrentSlice == null) - targetObject = Root; - else - targetObject = CurrentSlice.Object; - if (targetObject != null) - { - var owner = targetObject.Owner; - if (tool == "concordance") - { - int flidSlice = 0; - if (CurrentSlice != null && !CurrentSlice.IsHeaderNode) - { - flidSlice = CurrentSlice.Flid; - if (flidSlice == 0 || m_mdc.get_IsVirtual(flidSlice)) - return cmd.TargetId; - } - switch (className) - { - case "LexEntry": - if (m_root != null && m_root.ClassID == LexEntryTags.kClassId) - { - if (cmd.Id == "CmdRootEntryJumpToConcordance") - { - return m_root.Guid; - } - - if (targetObject.ClassID == LexEntryRefTags.kClassId) - return cmd.TargetId; - - if (targetObject.ClassID == LexEntryTags.kClassId) - return targetObject.Guid; - - var lexEntry = targetObject.OwnerOfClass(); - return lexEntry == null ? cmd.TargetId : lexEntry.Guid; - } - break; - case "LexSense": - if (targetObject.ClassID == LexSenseTags.kClassId) - { - if (((ILexSense)targetObject).Entry == m_root) - return targetObject.Guid; - } - break; - case "MoForm": - if (m_cache.ClassIsOrInheritsFrom(targetObject.ClassID, MoFormTags.kClassId)) - { - if (flidSlice == MoFormTags.kflidForm) - return targetObject.Guid; - } - break; - } - } - else if (tool == "lexiconEdit") - { - if (owner != null && owner != m_root && owner.ClassID == LexEntryTags.kClassId) - { - return owner.Guid; - } - } - else if (tool == "notebookEdit") - { - if (owner != null && - owner.ClassID == RnGenericRecTags.kClassId) - return owner.Guid; - if (targetObject is IText) - { - IRnGenericRec referringRecord; - if (NotebookRecordRefersToThisText(targetObject as IText, out referringRecord)) - return referringRecord.Guid; - - // Text is not already associated with a notebook record. So there's nothing yet to jump to. - // If the user is really doing the jump we need to make it now. - // Otherwise we just need to return something non-null to indicate the jump - // is possible (though this is not currently used). - if (forEnableOnly) - return targetObject.Guid; - // User is really making the jump. Create a notebook record, associate it, and jump. - var newNotebookRec = CreateAndAssociateNotebookRecord(); - return newNotebookRec.Guid; - } - // Try TargetId by default - } - else if (tool == "interlinearEdit") - { - if (targetObject.ClassID == TextTags.kClassId) - { - return targetObject.Guid; - } - } - } - return cmd.TargetId; - } - - private IRnGenericRec CreateAndAssociateNotebookRecord() - { - if (!(CurrentSlice.Object is IText)) - throw new ArgumentException("CurrentSlice.Object ought to be a Text object."); - - IText textObj = CurrentSlice.Object as IText; - // Create new Notebook record - - ((IText)CurrentSlice.Object).AssociateWithNotebook(true); - IRnGenericRec referringRecord; - NotebookRecordRefersToThisText(CurrentSlice.Object as IText, out referringRecord); - return referringRecord; + var target = CurrentSlice == null ? m_root : CurrentSlice.Object; + int? flid = CurrentSlice != null && !CurrentSlice.IsHeaderNode ? CurrentSlice.Flid : (int?)null; + var jump = DetailJumpTargets.Resolve(tool, className, cmd.Id, cmd.TargetId, m_root, target, flid); + if (jump.TextWithoutRecord != null && !forEnableOnly) + return DetailJumpTargets.AssociateWithNotebook(jump.TextWithoutRecord).Guid; + return jump.Guid; } internal static bool NotebookRecordRefersToThisText(IText text, out IRnGenericRec referringRecord) diff --git a/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.Designer.cs b/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.Designer.cs index bab06d80d2..8f9f66a45e 100644 --- a/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.Designer.cs +++ b/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.Designer.cs @@ -70,6 +70,15 @@ internal static System.Drawing.Bitmap gotoLinkPic { } } + /// + /// Looks up a localized string similar to Alternate Forms. + /// + internal static string ksAlternateForms { + get { + return ResourceManager.GetString("ksAlternateForms", resourceCulture); + } + } + /// /// Looks up a localized string similar to Editor type '{0}' not implemented.. /// @@ -484,6 +493,24 @@ internal static string ksSliceConfigureWssDlgTitle { } } + /// + /// Looks up a localized string similar to Swap Lexeme Form with Allomorph. + /// + internal static string ksSwapLexWithAllo { + get { + return ResourceManager.GetString("ksSwapLexWithAllo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Swap: "{0}"{2}With: "{1}". + /// + internal static string ksSwapXWithY { + get { + return ResourceManager.GetString("ksSwapXWithY", resourceCulture); + } + } + /// /// Looks up a localized string similar to Undo Alphabetical Order. /// diff --git a/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.resx b/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.resx index 7c4f1ff792..e992d0e1fa 100644 --- a/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.resx +++ b/Src/Common/Controls/DetailControls/Resources/DetailControlsStrings.resx @@ -318,4 +318,13 @@ Context menus are disabled in popup windows. + + Swap Lexeme Form with Allomorph + + + Alternate Forms + + + Swap: "{0}"{2}With: "{1}" + \ No newline at end of file diff --git a/Src/LexText/Lexicon/SwapLexemeWithAllomorphDlg.cs b/Src/Common/Controls/DetailControls/SwapLexemeWithAllomorphDlg.cs similarity index 97% rename from Src/LexText/Lexicon/SwapLexemeWithAllomorphDlg.cs rename to Src/Common/Controls/DetailControls/SwapLexemeWithAllomorphDlg.cs index e74a62a185..e99bfe930f 100644 --- a/Src/LexText/Lexicon/SwapLexemeWithAllomorphDlg.cs +++ b/Src/Common/Controls/DetailControls/SwapLexemeWithAllomorphDlg.cs @@ -6,6 +6,7 @@ using System.Drawing; using System.Windows.Forms; using System.Diagnostics; +using SIL.FieldWorks.Common.Framework.DetailControls.Resources; using SIL.FieldWorks.Common.Widgets; using SIL.LCModel; using XCore; @@ -14,7 +15,7 @@ using SIL.LCModel.Core.Text; using SIL.LCModel.Core.KernelInterfaces; -namespace SIL.FieldWorks.XWorks.LexEd +namespace SIL.FieldWorks.Common.Framework.DetailControls { /// /// Summary description for SwapLexemeWithAllomorphDlg. @@ -132,8 +133,8 @@ public void SetDlgInfo(LcmCache cache, PropertyTable propertyTable, ILexEntry en // StartPosition = FormStartPosition.Manual; //} m_lvAlloOptions.Items[0].Selected = true; - Text = LexEdStrings.ksSwapLexWithAllo; - label2.Text = LexEdStrings.ksAlternateForms; + Text = DetailControlsStrings.ksSwapLexWithAllo; + label2.Text = DetailControlsStrings.ksAlternateForms; // Determine the help file to use, if any m_helpTopic = "khtpSwapLexemeWithAllomorph"; @@ -193,7 +194,7 @@ private void SetBottomMessage() m_fwTextBoxBottomMsg.WritingSystemCode = userWs; string sLexVal = m_entry.LexemeFormOA.Form.VernacularDefaultWritingSystem.Text ?? ""; // Treat null value as empty string. This fixes LT-5889, LT-5891, and LT-5914. - string sFmt = LexEdStrings.ksSwapXWithY; + string sFmt = DetailControlsStrings.ksSwapXWithY; string sWithVal = m_allomorph.Form.VernacularDefaultWritingSystem.Text ?? ""; ITsString tss = TsStringUtils.MakeString(String.Format(sFmt, sLexVal, sWithVal, StringUtils.kChHardLB), userWs); m_fwTextBoxBottomMsg.Tss = tss; diff --git a/Src/LexText/Lexicon/SwapLexemeWithAllomorphDlg.resx b/Src/Common/Controls/DetailControls/SwapLexemeWithAllomorphDlg.resx similarity index 100% rename from Src/LexText/Lexicon/SwapLexemeWithAllomorphDlg.resx rename to Src/Common/Controls/DetailControls/SwapLexemeWithAllomorphDlg.resx diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs index 1a5d58e84e..072a7c31df 100644 --- a/Src/Common/FwAvalonia/Detail/DataTree.cs +++ b/Src/Common/FwAvalonia/Detail/DataTree.cs @@ -112,6 +112,24 @@ internal IReadOnlyList VectorRows public void RequestRowFocusOnRebuild(IReadOnlyList rowAutomationIds) => RowFocusRequested = rowAutomationIds; + /// The object whose first shown row the rebuild that follows should focus; + /// 0 when none was requested. + internal int ObjectFocusRequested { get; private set; } + + /// + /// Asks the rebuild that follows to give keyboard focus to the first row showing + /// , for a gesture that replaced the focused row's object with + /// a new one (its rows have ids the outgoing view cannot know). + /// + public void RequestObjectFocusOnRebuild(int hvo) => ObjectFocusRequested = hvo; + + /// + /// Focuses the first shown row of the object with that can + /// take focus; false when none can. + /// + internal bool FocusFirstRowOf(int hvo) + => hvo != 0 && FocusFirstRow(Model.Fields.Where(f => f.ObjectHvo == hvo).Select(f => f.RowAutomationId)); + /// /// Focuses the first row of that is shown and /// can take focus; false when none can. diff --git a/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs b/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs index 45bfc7aaeb..468944b886 100644 --- a/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs +++ b/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs @@ -31,7 +31,8 @@ public sealed class Memento { public Memento(string automationId, int caretIndex, double verticalOffset = 0, string vectorAutomationId = null, string vectorItemKey = null, int vectorItemIndex = -1, - bool focusVectorItem = false, IReadOnlyList rowAutomationIds = null) + bool focusVectorItem = false, IReadOnlyList rowAutomationIds = null, + int objectHvo = 0) { AutomationId = automationId; CaretIndex = caretIndex; @@ -41,8 +42,15 @@ public Memento(string automationId, int caretIndex, double verticalOffset = 0, VectorItemIndex = vectorItemIndex; FocusVectorItem = focusVectorItem; RowAutomationIds = rowAutomationIds; + ObjectHvo = objectHvo; } + /// + /// The object whose first row the outgoing view asked to focus after the rebuild + /// (a gesture replaced the focused row's object); 0 when none. + /// + public int ObjectHvo { get; } + /// /// The automation ids of the rows the outgoing view asked to try focusing after /// the rebuild, in order (a gesture removed the focused row and maybe its @@ -94,10 +102,11 @@ public static Memento Capture(Control root) var vectorIndex = selectedVector?.SelectedItemIndex ?? -1; var focusVector = tree?.VectorFocusRequested ?? false; var focusRow = tree?.RowFocusRequested; + var focusObject = tree?.ObjectFocusRequested ?? 0; var focusManager = TopLevel.GetTopLevel(root)?.FocusManager; if (!(focusManager?.GetFocusedElement() is Control focused) || !root.IsVisualAncestorOf(focused)) - return new Memento(null, -1, verticalOffset, vectorId, vectorKey, vectorIndex, focusVector, focusRow); + return new Memento(null, -1, verticalOffset, vectorId, vectorKey, vectorIndex, focusVector, focusRow, focusObject); // The editor itself carries the stable id (e.g. "LexemeFormEditor.vern"); walk up in // case focus landed on an inner template part. @@ -107,11 +116,11 @@ public static Memento Capture(Control root) if (!string.IsNullOrEmpty(id)) { return new Memento(id, (focused as TextBox)?.CaretIndex ?? -1, verticalOffset, - vectorId, vectorKey, vectorIndex, focusVector, focusRow); + vectorId, vectorKey, vectorIndex, focusVector, focusRow, focusObject); } } - return new Memento(null, -1, verticalOffset, vectorId, vectorKey, vectorIndex, focusVector, focusRow); + return new Memento(null, -1, verticalOffset, vectorId, vectorKey, vectorIndex, focusVector, focusRow, focusObject); } // Whether the rebuilt view should focus the vector row's current item: the outgoing view @@ -220,7 +229,8 @@ public static bool TryRestoreScroll(Control root, Memento memento) /// vector row's current item, then focus -- the remembered editor, else that 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). Safe to call before the view is attached. + /// gesture removed the focused row), else the first row of the object it asked for (a + /// gesture replaced the row's object). Safe to call before the view is attached. /// public static void RestoreAfterLayout(Control root, Memento memento) { @@ -246,6 +256,8 @@ public static void RestoreAfterLayout(Control root, Memento memento) vector.FocusItemAt(vector.SelectedItemIndex); else if (memento.RowAutomationIds != null) (root as DataTree)?.FocusFirstRow(memento.RowAutomationIds); + else if (memento.ObjectHvo != 0) + (root as DataTree)?.FocusFirstRowOf(memento.ObjectHvo); } // A bring-into-view request queued by a focus change is applied by the next // arrange; the remembered offset is re-asserted after it. diff --git a/Src/Common/FwAvalonia/DetailHostControl.cs b/Src/Common/FwAvalonia/DetailHostControl.cs index 005e2c047d..66b054f928 100644 --- a/Src/Common/FwAvalonia/DetailHostControl.cs +++ b/Src/Common/FwAvalonia/DetailHostControl.cs @@ -101,6 +101,12 @@ public void ShowDetail(DetailModel detail, IDetailEditContext editContext = null public void FocusRowOnNextShow(IReadOnlyList rowAutomationIds) => (CurrentContent as DataTree)?.RequestRowFocusOnRebuild(rowAutomationIds); + /// + /// Asks the next re-show to give keyboard focus to the first row showing the object + /// with , for a gesture that replaced the focused row's object. + /// + public void FocusObjectOnNextShow(int hvo) => (CurrentContent as DataTree)?.RequestObjectFocusOnRebuild(hvo); + /// The detail model shown now; null before the first show. public DetailModel ShownDetail => (CurrentContent as DataTree)?.Model; } diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailFocusMemoryTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailFocusMemoryTests.cs index 9e3d108c5d..8420bea9e1 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailFocusMemoryTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailFocusMemoryTests.cs @@ -107,6 +107,48 @@ public void RequestedRowFocus_YieldsToTheRememberedEditor_WhenItSurvives() Assert.That(FindEditor(second, "GlossEditor.vern").IsFocused, Is.True); } + // A gesture replaced the focused row's object with a new one, whose rows carry ids the + // outgoing view cannot know: the first row showing that object takes focus. + [AvaloniaTest] + public void RequestedObjectFocus_FocusesTheFirstRowShowingTheObject() + { + var first = NewViewOfForm(8); + var window = new Window { Content = first, Width = 420, Height = 200 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + FindEditor(first, "LexEntry/identity/#0@8.vern").Focus(); + Dispatcher.UIThread.RunJobs(); + + first.RequestObjectFocusOnRebuild(9); + var memento = DetailFocusMemory.Capture(first); + Assert.That(memento.AutomationId, Is.EqualTo("LexEntry/identity/#0@8.vern"), "precondition: the replaced form's editor had focus"); + Assert.That(memento.ObjectHvo, Is.EqualTo(9)); + + var second = NewViewOfForm(9); + DetailFocusMemory.RestoreAfterLayout(second, memento); + window.Content = second; + Dispatcher.UIThread.RunJobs(); + + Assert.That(FindEditor(second, "LexEntry/identity/#0@8.vern"), Is.Null, "precondition: the remembered editor is gone"); + Assert.That(FindEditor(second, "LexEntry/identity/#0@9.vern").IsFocused, Is.True, + "the new form's editor takes focus"); + } + + // A view whose Lexeme Form row shows the form with the given hvo, its row ids derived + // from the stable ids as the composer derives them. + private static DataTree NewViewOfForm(int formHvo) + { + var values = new List { new DetailWsValue("vern", "casa") }; + var fields = new List + { + new DetailField("LexEntry/identity/#0@" + formHvo, "Lexeme Form", "Form", "vernacular", DetailFieldKind.Text, + EditorClassification.Known, null, null, HostRouting.Product, values, null, null, objectHvo: formHvo), + new DetailField("LexEntry/identity/#1@3", "Gloss", "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/DetailJumpTargets.cs b/Src/FdoUi/DetailRules/DetailJumpTargets.cs new file mode 100644 index 0000000000..a004f9b6d1 --- /dev/null +++ b/Src/FdoUi/DetailRules/DetailJumpTargets.cs @@ -0,0 +1,143 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using SIL.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 target of a detail row's jump command (message JumpToTool): the object the + /// command's tool should show, resolved from the shown root, the row's object and the + /// row's field. + /// + public static class DetailJumpTargets + { + /// The command whose concordance jump always targets the root entry. + public const string RootEntryConcordanceCommandId = "CmdRootEntryJumpToConcordance"; + + public const string ConcordanceTool = "concordance"; + public const string LexiconEditTool = "lexiconEdit"; + public const string NotebookEditTool = "notebookEdit"; + public const string InterlinearEditTool = "interlinearEdit"; + + /// + /// Resolves the jump target. In the concordance the row's field decides: it must be a + /// real (non-virtual) field, and a form row must show the form itself. The lexicon + /// jump goes to the entry owning the row's object when that is not the root; the + /// notebook jump to the record owning it or associated with it; the interlinear jump + /// to the row's text. + /// + /// The command's id; + /// targets the root whatever the row. + /// Returned when the row offers no target, so the command + /// stays unanswered: the command's own target when one was set, else empty. + /// The object the detail view shows; null when none. + /// The row's object, or the root when no row is current; null + /// resolves to . + /// The row's field; null when no row is current or the row is a + /// header, 0 when the row's field is unknown. + public static DetailJumpTarget Resolve(string tool, string className, string commandId, + Guid fallback, ICmObject root, ICmObject target, int? flid) + { + if (target == null) + return new DetailJumpTarget(fallback); + var owner = target.Owner; + switch (tool) + { + case ConcordanceTool: + return new DetailJumpTarget(ConcordanceTarget(className, commandId, fallback, root, target, flid)); + case LexiconEditTool: + if (owner != null && owner != root && owner.ClassID == LexEntryTags.kClassId) + return new DetailJumpTarget(owner.Guid); + break; + case NotebookEditTool: + if (owner != null && owner.ClassID == RnGenericRecTags.kClassId) + return new DetailJumpTarget(owner.Guid); + if (target is IText text) + { + var record = text.AssociatedNotebookRecord; + return record != null + ? new DetailJumpTarget(record.Guid) + : new DetailJumpTarget(text.Guid, text); + } + break; + case InterlinearEditTool: + if (target.ClassID == TextTags.kClassId) + return new DetailJumpTarget(target.Guid); + break; + } + return new DetailJumpTarget(fallback); + } + + private static Guid ConcordanceTarget(string className, string commandId, Guid fallback, + ICmObject root, ICmObject target, int? flid) + { + var cache = target.Cache; + if (flid.HasValue && (flid.Value == 0 || cache.MetaDataCacheAccessor.get_IsVirtual(flid.Value))) + return fallback; + switch (className) + { + case "LexEntry": + if (root == null || root.ClassID != LexEntryTags.kClassId) + break; + if (commandId == RootEntryConcordanceCommandId) + return root.Guid; + if (target.ClassID == LexEntryRefTags.kClassId) + return fallback; + if (target.ClassID == LexEntryTags.kClassId) + return target.Guid; + var entry = target.OwnerOfClass(); + return entry == null ? fallback : entry.Guid; + case "LexSense": + if (target.ClassID == LexSenseTags.kClassId && ((ILexSense)target).Entry == root) + return target.Guid; + break; + case "MoForm": + if (cache.ClassIsOrInheritsFrom(target.ClassID, MoFormTags.kClassId) + && flid == MoFormTags.kflidForm) + { + return target.Guid; + } + break; + } + return fallback; + } + + /// + /// Creates the notebook record for a text that has none and associates the two; the + /// jump then shows that record. + /// + public static IRnGenericRec AssociateWithNotebook(IText text) + { + if (text == null) + throw new ArgumentNullException(nameof(text)); + text.AssociateWithNotebook(true); + return text.AssociatedNotebookRecord; + } + } + + /// A resolved jump target. + public sealed class DetailJumpTarget + { + public DetailJumpTarget(Guid guid, IText textWithoutRecord = null) + { + Guid = guid; + TextWithoutRecord = textWithoutRecord; + } + + /// The object to show, or the resolver's fallback when the row offers + /// none. + public Guid Guid { get; } + + /// + /// The text the notebook jump targets although it has no record yet: the jump must + /// first create one through + /// and show that. Null for every other target. + /// + public IText TextWithoutRecord { get; } + } +} diff --git a/Src/FdoUi/DetailRules/LexemeFormConversion.cs b/Src/FdoUi/DetailRules/LexemeFormConversion.cs new file mode 100644 index 0000000000..e1f92e920e --- /dev/null +++ b/Src/FdoUi/DetailRules/LexemeFormConversion.cs @@ -0,0 +1,124 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using SIL.FieldWorks.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 commands that change which object is an entry's lexeme form: swapping it with an + /// allomorph, and converting a form to another class (messages SwapLexemeWithAllomorph, + /// SwapAllomorphWithLexeme, ConvertLexemeForm and ConvertAllomorph). + /// + public static class LexemeFormConversion + { + /// Whether the entry has a lexeme form and an allomorph to swap it + /// with. + public static bool CanSwap(ILexEntry entry) + => entry != null && entry.LexemeFormOA != null && entry.AlternateFormsOS.Count > 0; + + /// + /// Makes the entry's lexeme form and puts the old lexeme + /// form in its place among the allomorphs, in one undoable step. + /// + public static void Swap(ILexEntry entry, IMoForm allomorph, string undoText, string redoText) + { + if (entry == null) + throw new ArgumentNullException(nameof(entry)); + if (allomorph == null) + throw new ArgumentNullException(nameof(allomorph)); + UndoableUnitOfWorkHelper.Do(undoText, redoText, entry, () => + { + entry.AlternateFormsOS.Insert(allomorph.IndexInOwner, entry.LexemeFormOA); + entry.LexemeFormOA = allomorph; + }); + } + + /// Whether is of the class a conversion starts + /// from. + public static bool CanConvert(IMoForm form, int fromClassId) + => form != null && fromClassId != 0 && form.ClassID == fromClassId; + + /// The title of the warning is shown + /// under. + public static string DataLossCaption => FdoUiStrings.ksConvertFormLoseCaption; + + /// + /// The warning to confirm before converting , naming what the + /// conversion loses: an affix allomorph's environments, infix positions and + /// grammatical information, or an affix process's rule. Null when nothing is lost. + /// + public static string DataLossMessage(IMoForm form) + { + switch (form) + { + case IMoAffixAllomorph allomorph: + var losesEnvironments = allomorph.PhoneEnvRC.Count > 0; + var losesPositions = allomorph.PositionRS.Count > 0; + var losesGrammar = allomorph.MsEnvFeaturesOA != null || allomorph.MsEnvPartOfSpeechRA != null; + if (losesEnvironments && losesPositions && losesGrammar) + return FdoUiStrings.ksConvertFormLoseEnvInfixLocGramInfo; + if (losesEnvironments && losesPositions) + return FdoUiStrings.ksConvertFormLoseEnvInfixLoc; + if (losesEnvironments && losesGrammar) + return FdoUiStrings.ksConvertFormLoseEnvGramInfo; + if (losesPositions && losesGrammar) + return FdoUiStrings.ksConvertFormLoseInfixLocGramInfo; + if (losesEnvironments) + return FdoUiStrings.ksConvertFormLoseEnv; + if (losesPositions) + return FdoUiStrings.ksConvertFormLoseInfixLoc; + if (losesGrammar) + return FdoUiStrings.ksConvertFormLoseGramInfo; + return null; + case IMoAffixProcess _: + return FdoUiStrings.ksConvertFormLoseRule; + default: + return null; + } + } + + /// + /// Replaces in with a new form of + /// carrying what the two classes share, in one undoable + /// step. + /// + /// MoAffixProcess, MoAffixAllomorph or MoStemAllomorph. + /// The new form. + public static IMoForm Convert(ILexEntry entry, IMoForm form, int toClassId, string undoText, string redoText) + { + if (entry == null) + throw new ArgumentNullException(nameof(entry)); + if (form == null) + throw new ArgumentNullException(nameof(form)); + IMoForm newForm = null; + UndoableUnitOfWorkHelper.Do(undoText, redoText, entry, () => + { + newForm = CreateForm(entry, toClassId); + entry.ReplaceMoForm(form, newForm); + }); + return newForm; + } + + private static IMoForm CreateForm(ILexEntry entry, int classId) + { + switch (classId) + { + case MoAffixProcessTags.kClassId: + return entry.Services.GetInstance().Create(); + case MoAffixAllomorphTags.kClassId: + return entry.Services.GetInstance().Create(); + case MoStemAllomorphTags.kClassId: + return entry.Services.GetInstance().Create(); + default: + throw new ArgumentOutOfRangeException(nameof(classId), classId, "Not a form class a conversion can produce."); + } + } + } +} diff --git a/Src/FdoUi/FdoUiStrings.Designer.cs b/Src/FdoUi/FdoUiStrings.Designer.cs index c547855370..1c08db5f11 100644 --- a/Src/FdoUi/FdoUiStrings.Designer.cs +++ b/Src/FdoUi/FdoUiStrings.Designer.cs @@ -177,6 +177,87 @@ internal static string ksCantModifyTemplateInUse { } } + /// + /// Looks up a localized string similar to Warning!. + /// + internal static string ksConvertFormLoseCaption { + get { + return ResourceManager.GetString("ksConvertFormLoseCaption", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The environment will be lost! Do you still want to continue?. + /// + internal static string ksConvertFormLoseEnv { + get { + return ResourceManager.GetString("ksConvertFormLoseEnv", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The environment and some grammatical information will be lost! Do you still want to continue?. + /// + internal static string ksConvertFormLoseEnvGramInfo { + get { + return ResourceManager.GetString("ksConvertFormLoseEnvGramInfo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The environment and infix position will be lost! Do you still want to continue?. + /// + internal static string ksConvertFormLoseEnvInfixLoc { + get { + return ResourceManager.GetString("ksConvertFormLoseEnvInfixLoc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The environment, infix position, and some grammatical information will be lost! Do you still want to continue?. + /// + internal static string ksConvertFormLoseEnvInfixLocGramInfo { + get { + return ResourceManager.GetString("ksConvertFormLoseEnvInfixLocGramInfo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Some grammatical information will be lost! Do you still want to continue?. + /// + internal static string ksConvertFormLoseGramInfo { + get { + return ResourceManager.GetString("ksConvertFormLoseGramInfo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The infix position will be lost! Do you still want to continue?. + /// + internal static string ksConvertFormLoseInfixLoc { + get { + return ResourceManager.GetString("ksConvertFormLoseInfixLoc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The infix position and some grammatical information will be lost! Do you still want to continue?. + /// + internal static string ksConvertFormLoseInfixLocGramInfo { + get { + return ResourceManager.GetString("ksConvertFormLoseInfixLocGramInfo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The affix process rule will be lost! Do you still want to continue?. + /// + internal static string ksConvertFormLoseRule { + get { + return ResourceManager.GetString("ksConvertFormLoseRule", resourceCulture); + } + } + /// /// Looks up a localized string similar to (Ctrl-Click). /// diff --git a/Src/FdoUi/FdoUiStrings.resx b/Src/FdoUi/FdoUiStrings.resx index 064af934c9..d3f1b4cf1a 100644 --- a/Src/FdoUi/FdoUiStrings.resx +++ b/Src/FdoUi/FdoUiStrings.resx @@ -340,4 +340,31 @@ Without these, we cannot find related entries. Sorry, FieldWorks cannot delete this wordform because there are parsing annotations attached. Please invoke "Remove Parser annotations" in Tools > Utilities first. + + Warning! + + + The environment will be lost! Do you still want to continue? + + + The environment and some grammatical information will be lost! Do you still want to continue? + + + The environment and infix position will be lost! Do you still want to continue? + + + The environment, infix position, and some grammatical information will be lost! Do you still want to continue? + + + Some grammatical information will be lost! Do you still want to continue? + + + The infix position will be lost! Do you still want to continue? + + + The infix position and some grammatical information will be lost! Do you still want to continue? + + + The affix process rule will be lost! Do you still want to continue? + \ No newline at end of file diff --git a/Src/FdoUi/FdoUiTests/DetailRules/DetailJumpTargetsTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/DetailJumpTargetsTests.cs new file mode 100644 index 0000000000..a8d57e4051 --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/DetailJumpTargetsTests.cs @@ -0,0 +1,175 @@ +// 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 NUnit.Framework; +using SIL.FieldWorks.Common.DetailRules; +using SIL.LCModel; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.FdoUi +{ + /// + /// The jump rule of a detail row: which object each tool's jump shows for the row's + /// object and field, and the notebook record a text's jump creates. + /// + [TestFixture] + public class DetailJumpTargetsTests : MemoryOnlyBackendProviderTestBase + { + private static readonly Guid Fallback = Guid.NewGuid(); + + private ILexEntry _entry; + private ILexSense _sense; + private ILexEntry _other; + private IText _text; + + // Two entries with lexeme forms and a sense each, and a text with no notebook record. + [SetUp] + public void MakeObjects() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + _entry = MakeEntry(); + _sense = _entry.SensesOS[0]; + _other = MakeEntry(); + _text = Cache.ServiceLocator.GetInstance().Create(); + }); + } + + private ILexEntry MakeEntry() + { + var entry = Cache.ServiceLocator.GetInstance().Create(); + entry.LexemeFormOA = Cache.ServiceLocator.GetInstance().Create(); + entry.SensesOS.Add(Cache.ServiceLocator.GetInstance().Create()); + return entry; + } + + private static Guid Concordance(string className, ICmObject root, ICmObject target, int? flid, + string commandId = "CmdJumpToConcordance") + => DetailJumpTargets.Resolve(DetailJumpTargets.ConcordanceTool, className, commandId, Fallback, root, target, flid).Guid; + + [Test] + public void Concordance_TheRootEntryCommand_TargetsTheRoot_WhateverTheRow() + { + Assert.That(Concordance("LexEntry", _entry, _sense, LexSenseTags.kflidGloss, + DetailJumpTargets.RootEntryConcordanceCommandId), Is.EqualTo(_entry.Guid)); + Assert.That(Concordance("LexEntry", _entry, _entry, null, + DetailJumpTargets.RootEntryConcordanceCommandId), Is.EqualTo(_entry.Guid), "no current row"); + } + + [Test] + public void Concordance_AnEntry_IsTheRowsEntry_OrTheOneOwningTheRowsObject() + { + Assert.That(Concordance("LexEntry", _entry, _entry, LexEntryTags.kflidCitationForm), Is.EqualTo(_entry.Guid)); + Assert.That(Concordance("LexEntry", _entry, _entry.LexemeFormOA, MoFormTags.kflidForm), Is.EqualTo(_entry.Guid), + "a form row jumps to the entry owning the form"); + Assert.That(Concordance("LexEntry", _entry, _other.LexemeFormOA, MoFormTags.kflidForm), Is.EqualTo(_other.Guid), + "another entry's form row jumps to that entry"); + Assert.That(Concordance("LexEntry", _entry, _entry, null), Is.EqualTo(_entry.Guid), "a header row has no field"); + } + + [Test] + public void Concordance_AnEntry_IsNotOffered_OnAnEntryRefRow_OrWhenTheRootIsNoEntry() + { + ILexEntryRef entryRef = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + entryRef = Cache.ServiceLocator.GetInstance().Create(); + _entry.EntryRefsOS.Add(entryRef); + }); + + Assert.That(Concordance("LexEntry", _entry, entryRef, LexEntryRefTags.kflidComponentLexemes), Is.EqualTo(Fallback)); + Assert.That(Concordance("LexEntry", _sense, _entry, LexEntryTags.kflidCitationForm), Is.EqualTo(Fallback), + "the rule needs an entry as root"); + } + + [Test] + public void Concordance_ASense_OnlyWhenItIsTheRootsOwn() + { + Assert.That(Concordance("LexSense", _entry, _sense, LexSenseTags.kflidGloss), Is.EqualTo(_sense.Guid)); + Assert.That(Concordance("LexSense", _entry, _other.SensesOS[0], LexSenseTags.kflidGloss), Is.EqualTo(Fallback)); + Assert.That(Concordance("LexSense", _entry, _entry, LexEntryTags.kflidCitationForm), Is.EqualTo(Fallback)); + } + + [Test] + public void Concordance_AForm_OnlyFromTheFormFieldItself() + { + var form = _entry.LexemeFormOA; + Assert.That(Concordance("MoForm", _entry, form, MoFormTags.kflidForm), Is.EqualTo(form.Guid)); + Assert.That(Concordance("MoForm", _entry, form, MoFormTags.kflidIsAbstract), Is.EqualTo(Fallback), + "a sibling row on the same form does not show the form"); + Assert.That(Concordance("MoForm", _entry, form, null), Is.EqualTo(Fallback), "a header row shows no field"); + Assert.That(Concordance("MoForm", _entry, _entry, LexEntryTags.kflidCitationForm), Is.EqualTo(Fallback)); + } + + [Test] + public void Concordance_DeclinesARow_WhoseFieldIsUnknownOrVirtual() + { + var virtualFlid = Cache.MetaDataCacheAccessor.GetFieldId2(LexEntryTags.kClassId, "VariantFormEntryBackRefs", false); + Assert.That(Cache.MetaDataCacheAccessor.get_IsVirtual(virtualFlid), Is.True, "precondition"); + + Assert.That(Concordance("LexEntry", _entry, _entry, 0), Is.EqualTo(Fallback)); + Assert.That(Concordance("LexEntry", _entry, _entry, virtualFlid), Is.EqualTo(Fallback)); + } + + [Test] + public void LexiconEdit_TargetsTheEntryOwningTheRowsObject_UnlessThatIsTheRoot() + { + Guid Lexicon(ICmObject target) => DetailJumpTargets.Resolve(DetailJumpTargets.LexiconEditTool, "LexEntry", + "CmdEntryJumpToDefault", Fallback, _entry, target, MoFormTags.kflidForm).Guid; + + Assert.That(Lexicon(_other.LexemeFormOA), Is.EqualTo(_other.Guid), "a variant's form row jumps to the variant"); + Assert.That(Lexicon(_entry.LexemeFormOA), Is.EqualTo(Fallback), "the root's own form offers no jump"); + Assert.That(Lexicon(_other.SensesOS[0]), Is.EqualTo(_other.Guid)); + Assert.That(Lexicon(_other), Is.EqualTo(Fallback), "an entry row is owned by no entry"); + } + + [Test] + public void NotebookEdit_TargetsTheRecordOwningTheRowsObject_OrTheTextsRecord() + { + DetailJumpTarget Notebook(ICmObject target) => DetailJumpTargets.Resolve(DetailJumpTargets.NotebookEditTool, + "RnGenericRec", "CmdJumpToNotebook", Fallback, _text, target, null); + + var before = Notebook(_text); + Assert.That(before.Guid, Is.EqualTo(_text.Guid), "a text without a record is offered on its own guid"); + Assert.That(before.TextWithoutRecord, Is.SameAs(_text), "and the jump must create the record first"); + + // The association runs its own unit of work, as it does from a jump. + var record = DetailJumpTargets.AssociateWithNotebook(_text); + Assert.That(record, Is.Not.Null.And.SameAs(_text.AssociatedNotebookRecord), "the record is created and associated"); + IStText description = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + description = Cache.ServiceLocator.GetInstance().Create(); + record.DescriptionOA = description; + }); + + var after = Notebook(_text); + Assert.That(after.Guid, Is.EqualTo(record.Guid)); + Assert.That(after.TextWithoutRecord, Is.Null); + Assert.That(Notebook(description).Guid, Is.EqualTo(record.Guid), "a row of something the record owns"); + Assert.That(Notebook(_entry).Guid, Is.EqualTo(Fallback)); + } + + [Test] + public void InterlinearEdit_TargetsATextRow_Only() + { + Guid Interlinear(ICmObject target) => DetailJumpTargets.Resolve(DetailJumpTargets.InterlinearEditTool, "Text", + "CmdJumpToText", Fallback, _text, target, null).Guid; + + Assert.That(Interlinear(_text), Is.EqualTo(_text.Guid)); + Assert.That(Interlinear(_entry), Is.EqualTo(Fallback)); + } + + [Test] + public void NoRowObject_OrAnUnknownTool_FallsBack() + { + Assert.That(DetailJumpTargets.Resolve(DetailJumpTargets.ConcordanceTool, "LexEntry", "CmdEntryJumpToConcordance", + Fallback, _entry, null, null).Guid, Is.EqualTo(Fallback)); + Assert.That(DetailJumpTargets.Resolve("publicationsEdit", "ICmPossibility", "CmdPublicationsJumpToDefault", + Guid.Empty, _entry, _entry, null).Guid, Is.EqualTo(Guid.Empty)); + Assert.That(() => DetailJumpTargets.AssociateWithNotebook(null), Throws.ArgumentNullException); + } + } +} diff --git a/Src/FdoUi/FdoUiTests/DetailRules/LexemeFormConversionTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/LexemeFormConversionTests.cs new file mode 100644 index 0000000000..25f02cfb33 --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/LexemeFormConversionTests.cs @@ -0,0 +1,184 @@ +// 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 NUnit.Framework; +using SIL.FieldWorks.Common.DetailRules; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.FdoUi +{ + /// + /// The lexeme-form commands: when a swap or a conversion is offered, what each does to + /// the entry in one undoable step, and the warning a conversion needs. + /// + [TestFixture] + public class LexemeFormConversionTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry _entry; + private IMoStemAllomorph _lexemeForm; + private IMoStemAllomorph _allomorph; + + // A stem entry whose lexeme form has one allomorph. + [SetUp] + public void MakeEntry() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + _entry = Cache.ServiceLocator.GetInstance().Create(); + _lexemeForm = Cache.ServiceLocator.GetInstance().Create(); + _entry.LexemeFormOA = _lexemeForm; + SetForm(_lexemeForm, "casa"); + _allomorph = Cache.ServiceLocator.GetInstance().Create(); + _entry.AlternateFormsOS.Add(_allomorph); + SetForm(_allomorph, "cas"); + }); + } + + private void SetForm(IMoForm form, string text) + => form.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString(text, Cache.DefaultVernWs)); + + [Test] + public void CanSwap_NeedsALexemeForm_AndAnAllomorph() + { + Assert.That(LexemeFormConversion.CanSwap(_entry), Is.True); + Assert.That(LexemeFormConversion.CanSwap(null), Is.False); + + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => _entry.AlternateFormsOS.Clear()); + Assert.That(LexemeFormConversion.CanSwap(_entry), Is.False, "nothing to swap with"); + + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + _entry.AlternateFormsOS.Add(Cache.ServiceLocator.GetInstance().Create()); + _entry.LexemeFormOA = null; + }); + Assert.That(LexemeFormConversion.CanSwap(_entry), Is.False, "nothing to swap"); + } + + [Test] + public void Swap_MakesTheAllomorphTheLexemeForm_AndPutsTheOldOneInItsPlace_InOneUndoableStep() + { + IMoStemAllomorph second = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + second = Cache.ServiceLocator.GetInstance().Create(); + _entry.AlternateFormsOS.Add(second); + }); + + LexemeFormConversion.Swap(_entry, second, "Undo Swap", "Redo Swap"); + + Assert.That(_entry.LexemeFormOA, Is.SameAs(second)); + Assert.That(_entry.AlternateFormsOS, Is.EqualTo(new IMoForm[] { _allomorph, _lexemeForm }), + "the old lexeme form takes the swapped allomorph's position"); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("Undo Swap")); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(_entry.LexemeFormOA, Is.SameAs(_lexemeForm), "one Undo restores both changes"); + Assert.That(_entry.AlternateFormsOS, Is.EqualTo(new IMoForm[] { _allomorph, second })); + } + + [Test] + public void CanConvert_NeedsAFormOfTheClassTheCommandConvertsFrom() + { + Assert.That(LexemeFormConversion.CanConvert(_lexemeForm, MoStemAllomorphTags.kClassId), Is.True); + Assert.That(LexemeFormConversion.CanConvert(_lexemeForm, MoAffixAllomorphTags.kClassId), Is.False); + Assert.That(LexemeFormConversion.CanConvert(_lexemeForm, 0), Is.False, "an unknown class name"); + Assert.That(LexemeFormConversion.CanConvert(null, MoStemAllomorphTags.kClassId), Is.False, "no lexeme form"); + } + + [Test] + public void DataLossMessage_NamesWhatAnAffixAllomorphLoses_AndNothingForAStem() + { + IMoAffixAllomorph affix = null; + IPhEnvironment environment = null; + IPartOfSpeech pos = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + affix = Cache.ServiceLocator.GetInstance().Create(); + _entry.AlternateFormsOS.Add(affix); + environment = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.PhonologicalDataOA.EnvironmentsOS.Add(environment); + pos = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.PartsOfSpeechOA.PossibilitiesOS.Add(pos); + }); + void Set(bool environments, bool positions, bool grammar) => NonUndoableUnitOfWorkHelper.Do( + Cache.ActionHandlerAccessor, () => + { + affix.PhoneEnvRC.Clear(); + affix.PositionRS.Clear(); + affix.MsEnvPartOfSpeechRA = null; + if (environments) + affix.PhoneEnvRC.Add(environment); + if (positions) + affix.PositionRS.Add(environment); + if (grammar) + affix.MsEnvPartOfSpeechRA = pos; + }); + + Assert.That(LexemeFormConversion.DataLossMessage(_lexemeForm), Is.Null, "a stem allomorph loses nothing"); + Assert.That(LexemeFormConversion.DataLossMessage(null), Is.Null); + Assert.That(LexemeFormConversion.DataLossMessage(affix), Is.Null, "an empty affix allomorph loses nothing"); + Set(true, false, false); + Assert.That(LexemeFormConversion.DataLossMessage(affix), Is.EqualTo("The environment will be lost! Do you still want to continue?")); + Set(false, true, false); + Assert.That(LexemeFormConversion.DataLossMessage(affix), Is.EqualTo("The infix position will be lost! Do you still want to continue?")); + Set(false, false, true); + Assert.That(LexemeFormConversion.DataLossMessage(affix), Is.EqualTo("Some grammatical information will be lost! Do you still want to continue?")); + Set(true, true, false); + Assert.That(LexemeFormConversion.DataLossMessage(affix), Is.EqualTo("The environment and infix position will be lost! Do you still want to continue?")); + Set(true, false, true); + Assert.That(LexemeFormConversion.DataLossMessage(affix), Is.EqualTo("The environment and some grammatical information will be lost! Do you still want to continue?")); + Set(false, true, true); + Assert.That(LexemeFormConversion.DataLossMessage(affix), Is.EqualTo("The infix position and some grammatical information will be lost! Do you still want to continue?")); + Set(true, true, true); + Assert.That(LexemeFormConversion.DataLossMessage(affix), Is.EqualTo("The environment, infix position, and some grammatical information will be lost! Do you still want to continue?")); + Assert.That(LexemeFormConversion.DataLossCaption, Is.EqualTo("Warning!")); + } + + [Test] + public void DataLossMessage_AnAffixProcess_LosesItsRule() + { + IMoAffixProcess process = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + process = Cache.ServiceLocator.GetInstance().Create(); + _entry.AlternateFormsOS.Add(process); + }); + + Assert.That(LexemeFormConversion.DataLossMessage(process), Is.EqualTo("The affix process rule will be lost! Do you still want to continue?")); + } + + [Test] + public void Convert_ReplacesTheFormWithOneOfTheNewClass_KeepingItsText_InOneUndoableStep() + { + IMoAffixAllomorph affix = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + affix = Cache.ServiceLocator.GetInstance().Create(); + _entry.LexemeFormOA = affix; + SetForm(affix, "-ing"); + }); + + var converted = LexemeFormConversion.Convert(_entry, affix, MoAffixProcessTags.kClassId, "Undo Convert", "Redo Convert"); + + Assert.That(converted, Is.InstanceOf()); + Assert.That(_entry.LexemeFormOA, Is.SameAs(converted)); + Assert.That(converted.Form.get_String(Cache.DefaultVernWs).Text, Is.EqualTo("-ing")); + Assert.That(affix.IsValidObject, Is.False, "the old form is gone"); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("Undo Convert")); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(_entry.LexemeFormOA, Is.InstanceOf(), "one Undo restores the old form"); + Assert.That(_entry.LexemeFormOA.Form.get_String(Cache.DefaultVernWs).Text, Is.EqualTo("-ing")); + } + + [Test] + public void Convert_RefusesAClassNoConversionProduces() + { + Assert.That(() => LexemeFormConversion.Convert(_entry, _lexemeForm, LexEntryTags.kClassId, "u", "r"), + Throws.InstanceOf()); + Assert.That(_entry.LexemeFormOA, Is.SameAs(_lexemeForm), "the entry is untouched"); + } + } +} diff --git a/Src/LexText/Lexicon/LexEdStrings.Designer.cs b/Src/LexText/Lexicon/LexEdStrings.Designer.cs index 12c42f1f90..0e9c9cd71b 100644 --- a/Src/LexText/Lexicon/LexEdStrings.Designer.cs +++ b/Src/LexText/Lexicon/LexEdStrings.Designer.cs @@ -267,15 +267,6 @@ internal static string ksAlsoEmptyComplexFormInfo { } } - /// - /// Looks up a localized string similar to Alternate Forms. - /// - internal static string ksAlternateForms { - get { - return ResourceManager.GetString("ksAlternateForms", resourceCulture); - } - } - /// /// Looks up a localized string similar to Always visible. /// @@ -366,87 +357,6 @@ internal static string ksCircularRefsFixed { } } - /// - /// Looks up a localized string similar to Warning!. - /// - internal static string ksConvertFormLoseCaption { - get { - return ResourceManager.GetString("ksConvertFormLoseCaption", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The environment will be lost! Do you still want to continue?. - /// - internal static string ksConvertFormLoseEnv { - get { - return ResourceManager.GetString("ksConvertFormLoseEnv", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The environment and some grammatical information will be lost! Do you still want to continue?. - /// - internal static string ksConvertFormLoseEnvGramInfo { - get { - return ResourceManager.GetString("ksConvertFormLoseEnvGramInfo", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The environment and infix position will be lost! Do you still want to continue?. - /// - internal static string ksConvertFormLoseEnvInfixLoc { - get { - return ResourceManager.GetString("ksConvertFormLoseEnvInfixLoc", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The environment, infix position, and some grammatical information will be lost! Do you still want to continue?. - /// - internal static string ksConvertFormLoseEnvInfixLocGramInfo { - get { - return ResourceManager.GetString("ksConvertFormLoseEnvInfixLocGramInfo", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Some grammatical information will be lost! Do you still want to continue?. - /// - internal static string ksConvertFormLoseGramInfo { - get { - return ResourceManager.GetString("ksConvertFormLoseGramInfo", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The infix position will be lost! Do you still want to continue?. - /// - internal static string ksConvertFormLoseInfixLoc { - get { - return ResourceManager.GetString("ksConvertFormLoseInfixLoc", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The infix position and some grammatical information will be lost! Do you still want to continue?. - /// - internal static string ksConvertFormLoseInfixLocGramInfo { - get { - return ResourceManager.GetString("ksConvertFormLoseInfixLocGramInfo", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The affix process rule will be lost! Do you still want to continue?. - /// - internal static string ksConvertFormLoseRule { - get { - return ResourceManager.GetString("ksConvertFormLoseRule", resourceCulture); - } - } - /// /// Looks up a localized string similar to Convert variant types to irregularly inflected form variant types. /// @@ -1131,24 +1041,6 @@ internal static string ksStars { } } - /// - /// Looks up a localized string similar to Swap Lexeme Form with Allomorph. - /// - internal static string ksSwapLexWithAllo { - get { - return ResourceManager.GetString("ksSwapLexWithAllo", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Swap: "{0}"{2}With: "{1}". - /// - internal static string ksSwapXWithY { - get { - return ResourceManager.GetString("ksSwapXWithY", resourceCulture); - } - } - /// /// Looks up a localized string similar to standard. /// diff --git a/Src/LexText/Lexicon/LexEdStrings.resx b/Src/LexText/Lexicon/LexEdStrings.resx index 4ae891d96f..60ffb6a72f 100644 --- a/Src/LexText/Lexicon/LexEdStrings.resx +++ b/Src/LexText/Lexicon/LexEdStrings.resx @@ -131,9 +131,6 @@ &Replace - - Alternate Forms - Always visible menu item @@ -268,12 +265,6 @@ *** signals missing/unknown value - - Swap Lexeme Form with Allomorph - - - Swap: "{0}"{2}With: "{1}" - Undo Add Reversal Entry to Sense @@ -313,33 +304,6 @@ Show in Reversal Index (Ctrl-Click) - - Warning! - - - The environment will be lost! Do you still want to continue? - - - The environment and some grammatical information will be lost! Do you still want to continue? - - - The environment and infix position will be lost! Do you still want to continue? - - - The environment, infix position, and some grammatical information will be lost! Do you still want to continue? - - - Some grammatical information will be lost! Do you still want to continue? - - - The infix position will be lost! Do you still want to continue? - - - The infix position and some grammatical information will be lost! Do you still want to continue? - - - The affix process rule will be lost! Do you still want to continue? - Redo Add Component diff --git a/Src/LexText/Lexicon/LexEntryMenuHandler.cs b/Src/LexText/Lexicon/LexEntryMenuHandler.cs index 65d693b8b6..a14afac17c 100644 --- a/Src/LexText/Lexicon/LexEntryMenuHandler.cs +++ b/Src/LexText/Lexicon/LexEntryMenuHandler.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Windows.Forms; using System.Xml; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; @@ -374,15 +375,6 @@ public virtual bool OnDisplayPictureProperties(object commandObject, ref UIItemD return true; } - private void SwapAllomorphWithLexeme(ILexEntry entry, IMoForm allomorph, Command cmd) - { - UndoableUnitOfWorkHelper.Do(cmd.UndoText, cmd.RedoText, entry, () => - { - entry.AlternateFormsOS.Insert(allomorph.IndexInOwner, entry.LexemeFormOA); - entry.LexemeFormOA = allomorph; - }); - } - public virtual bool OnSwapAllomorphWithLexeme(object cmd) { Slice slice = m_dataEntryForm.CurrentSlice; @@ -390,7 +382,8 @@ public virtual bool OnSwapAllomorphWithLexeme(object cmd) IMoForm allomorph = slice.Object as IMoForm; if (entry != null && allomorph != null) { - SwapAllomorphWithLexeme(entry, allomorph, cmd as Command); + var command = (Command)cmd; + LexemeFormConversion.Swap(entry, allomorph, command.UndoText, command.RedoText); } return true; } @@ -416,7 +409,8 @@ public virtual bool OnSwapLexemeWithAllomorph(object cmd) dlg.SetDlgInfo(cache, m_propertyTable, entry); if (DialogResult.OK == dlg.ShowDialog(mainWindow)) { - SwapAllomorphWithLexeme(entry, dlg.SelectedAllomorph, cmd as Command); + var command = (Command)cmd; + LexemeFormConversion.Swap(entry, dlg.SelectedAllomorph, command.UndoText, command.RedoText); } } } @@ -426,8 +420,7 @@ public virtual bool OnSwapLexemeWithAllomorph(object cmd) public virtual bool OnDisplaySwapLexemeWithAllomorph(object commandObject, ref UIItemDisplayProperties display) { - ILexEntry entry = m_dataEntryForm.Root as ILexEntry; - bool enable = entry != null && entry.AlternateFormsOS.Count > 0; + bool enable = LexemeFormConversion.CanSwap(m_dataEntryForm.Root as ILexEntry); display.Visible = enable; display.Enabled = enable; return true; @@ -438,7 +431,7 @@ public virtual bool OnDisplayConvertLexemeForm(object commandObject, ref UIItemD Command cmd = commandObject as Command; int fromClsid = m_dataEntryForm.Cache.MetaDataCacheAccessor.GetClassId(cmd.GetParameter("fromClassName")); ILexEntry entry = m_dataEntryForm.Root as ILexEntry; - bool enable = entry != null && fromClsid != 0 && entry.LexemeFormOA.ClassID == fromClsid; + bool enable = entry != null && LexemeFormConversion.CanConvert(entry.LexemeFormOA, fromClsid); display.Visible = enable; display.Enabled = enable; return true; @@ -450,23 +443,7 @@ public bool OnConvertLexemeForm(object cmd) var toClsid = m_dataEntryForm.Cache.MetaDataCacheAccessor.GetClassId(command.GetParameter("toClassName")); var entry = m_dataEntryForm.Root as ILexEntry; if (entry != null) - { - if (CheckForFormDataLoss(entry.LexemeFormOA, toClsid)) - { - var mainWindow = m_propertyTable.GetValue
("window"); - IMoForm newForm = null; - using (new WaitCursor(mainWindow)) - { - UndoableUnitOfWorkHelper.Do(command.UndoText, command.RedoText, entry, () => - { - newForm = CreateNewForm(entry, toClsid); - entry.ReplaceMoForm(entry.LexemeFormOA, newForm); - }); - m_dataEntryForm.RefreshList(false); - } - SelectNewFormSlice(newForm); - } - } + ConvertForm(entry, entry.LexemeFormOA, toClsid, command); return true; } @@ -476,7 +453,7 @@ public virtual bool OnDisplayConvertAllomorph(object commandObject, ref UIItemDi int fromClsid = m_dataEntryForm.Cache.MetaDataCacheAccessor.GetClassId(cmd.GetParameter("fromClassName")); Slice slice = m_dataEntryForm.CurrentSlice; IMoForm allomorph = slice.Object as IMoForm; - bool enable = allomorph != null && fromClsid != 0 && allomorph.ClassID == fromClsid; + bool enable = LexemeFormConversion.CanConvert(allomorph, fromClsid); display.Visible = enable; display.Enabled = enable; return true; @@ -490,40 +467,24 @@ public bool OnConvertAllomorph(object cmd) var slice = m_dataEntryForm.CurrentSlice; var allomorph = slice.Object as IMoForm; if (entry != null && allomorph != null && toClsid != 0) - { - if (CheckForFormDataLoss(allomorph, toClsid)) - { - var mainWindow = m_propertyTable.GetValue("window"); - IMoForm newForm = null; - using (new WaitCursor(mainWindow)) - { - UndoableUnitOfWorkHelper.Do(command.UndoText, command.RedoText, entry, () => - { - newForm = CreateNewForm(entry, toClsid); - entry.ReplaceMoForm(allomorph, newForm); - }); - m_dataEntryForm.RefreshList(false); - } - SelectNewFormSlice(newForm); - } - } + ConvertForm(entry, allomorph, toClsid, command); return true; } - IMoForm CreateNewForm(ILexEntry parent, int clsid) + // Converts the form after the user accepts any data loss, then shows and selects the + // new form's slice. + private void ConvertForm(ILexEntry entry, IMoForm form, int toClsid, Command command) { - switch (clsid) + if (!CheckForFormDataLoss(form)) + return; + var mainWindow = m_propertyTable.GetValue("window"); + IMoForm newForm; + using (new WaitCursor(mainWindow)) { - case MoAffixProcessTags.kClassId: - return parent.Services.GetInstance().Create(); - - case MoAffixAllomorphTags.kClassId: - return parent.Services.GetInstance().Create(); - - case MoStemAllomorphTags.kClassId: - return parent.Services.GetInstance().Create(); + newForm = LexemeFormConversion.Convert(entry, form, toClsid, command.UndoText, command.RedoText); + m_dataEntryForm.RefreshList(false); } - return null; + SelectNewFormSlice(newForm); } void SelectNewFormSlice(IMoForm newForm) @@ -538,44 +499,12 @@ void SelectNewFormSlice(IMoForm newForm) } } - bool CheckForFormDataLoss(IMoForm origForm, int toClsid) + bool CheckForFormDataLoss(IMoForm origForm) { - string msg = null; - switch (origForm.ClassID) - { - case MoAffixAllomorphTags.kClassId: - IMoAffixAllomorph affAllo = origForm as IMoAffixAllomorph; - bool loseEnv = affAllo.PhoneEnvRC.Count > 0; - bool losePos = affAllo.PositionRS.Count > 0; - bool loseGram = affAllo.MsEnvFeaturesOA != null || affAllo.MsEnvPartOfSpeechRA != null; - if (loseEnv && losePos && loseGram) - msg = LexEdStrings.ksConvertFormLoseEnvInfixLocGramInfo; - else if (loseEnv && losePos) - msg = LexEdStrings.ksConvertFormLoseEnvInfixLoc; - else if (loseEnv && loseGram) - msg = LexEdStrings.ksConvertFormLoseEnvGramInfo; - else if (losePos && loseGram) - msg = LexEdStrings.ksConvertFormLoseInfixLocGramInfo; - else if (loseEnv) - msg = LexEdStrings.ksConvertFormLoseEnv; - else if (losePos) - msg = LexEdStrings.ksConvertFormLoseInfixLoc; - else if (loseGram) - msg = LexEdStrings.ksConvertFormLoseGramInfo; - break; - - case MoAffixProcessTags.kClassId: - msg = LexEdStrings.ksConvertFormLoseRule; - break; - - case MoStemAllomorphTags.kClassId: - // not implemented - break; - } - + string msg = LexemeFormConversion.DataLossMessage(origForm); if (msg != null) { - DialogResult result = MessageBox.Show(msg, LexEdStrings.ksConvertFormLoseCaption, + DialogResult result = MessageBox.Show(msg, LexemeFormConversion.DataLossCaption, MessageBoxButtons.YesNo, MessageBoxIcon.Warning); return result == DialogResult.Yes; } diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index 6cb004151f..3b7709e124 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -1045,21 +1045,14 @@ private bool TryMutateOverride(DetailField field, ViewOverrideOperation op) /// /// Follows a chooser jump link (e.g. "Edit the Publications list" on Publish In) the - /// EXACT way the legacy chooser does on link click -- the dialog closes, then - /// ReallySimpleListChooser.HandleAnyJump posts FollowLink with the - /// FwLinkArgs(tool, guid) built from the layout's chooserLink - /// (ReallySimpleListChooser.cs:900/1657). Here the flyout has already closed; any open - /// fenced edit session settles first (the jump navigates away from this record), then the - /// same mediator message posts. + /// way ReallySimpleListChooser.HandleAnyJump does on link click: the flyout has + /// already closed, then the link built from the layout's chooserLink is followed. /// private void OnDetailLinkRequested(DetailLinkRequest request) { try { - SettleDetailEdits(); -#pragma warning disable 618 // legacy parity: ReallySimpleListChooser.HandleAnyJump posts the same way - m_mediator.PostMessage("FollowLink", CreateFollowLinkArgs(request)); -#pragma warning restore 618 + FollowLink(CreateFollowLinkArgs(request)); } catch (Exception e) { @@ -1067,6 +1060,19 @@ private void OnDetailLinkRequested(DetailLinkRequest request) } } + /// + /// Follows a link from the detail view: pending edits settle first, since the jump + /// leaves the record, then the link is posted. Every native jump comes through here, + /// so replacing the post reaches them all at once. + /// + internal void FollowLink(FwLinkArgs link) + { + SettleDetailEdits(); +#pragma warning disable 618 // suppress obsolete warning + m_mediator.PostMessage("FollowLink", link); +#pragma warning restore 618 + } + /// /// The legacy translation: new FwLinkArgs(sTool, m_guidLink) -- the tool from the /// layout's chooserLink, the target guid empty unless the link resolved one (none of the diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs index 5018627c3e..4eaa2fb2bc 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs @@ -4,11 +4,16 @@ using System; using System.Collections.Generic; +using System.Windows.Forms; using SIL.FieldWorks.Common.DetailRules; +using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.FwUtils; using SIL.FieldWorks.FdoUi; using SIL.LCModel; +using SIL.LCModel.Infrastructure; using SIL.Reporting; +using SIL.Utils; using XCore; namespace SIL.FieldWorks.XWorks @@ -22,12 +27,37 @@ public partial class RecordEditView /// The message every Delete leaf of the detail menus sends. internal const string DeleteMessage = "DataTreeDelete"; + /// The message every jump leaf (Show ... in ...) of the detail menus + /// sends. + internal const string JumpMessage = "JumpToTool"; + + /// The message of Swap Lexeme Form with Allomorph. + internal const string SwapLexemeFormMessage = "SwapLexemeWithAllomorph"; + + /// The message of Convert to Affix Process / Convert to Affix Form. + internal const string ConvertLexemeFormMessage = "ConvertLexemeForm"; + /// /// 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(); + /// + /// Picks the allomorph to swap with the entry's lexeme form, or null to cancel: the + /// product asks with ; a headless test + /// answers for the user. + /// + internal Func AllomorphToSwap { get; set; } + + /// + /// Asks the user to accept the data a form conversion loses, given the warning text: + /// the product shows it; a headless test answers for the user. + /// + internal Func ConversionLossAccepted { get; set; } = warning => + MessageBox.Show(warning, LexemeFormConversion.DataLossCaption, MessageBoxButtons.YesNo, + MessageBoxIcon.Warning) == DialogResult.Yes; + /// /// The message-keyed authority for the Lexicon menus, owning by predicate and /// answering for ; with no row every answer is hidden. @@ -37,6 +67,9 @@ internal LexiconMenuAuthority CreateLexiconMenuAuthority(DetailField field) var window = m_propertyTable.GetValue("window"); var authority = new LexiconMenuAuthority(id => XCoreMenuBridge.ResolveMenu(window, id)); authority.Answer(DeleteMessage, leaf => DeleteItem(leaf, field)); + authority.Answer(JumpMessage, leaf => JumpItem(leaf, field)); + authority.Answer(SwapLexemeFormMessage, leaf => SwapLexemeFormItem(leaf, field)); + authority.Answer(ConvertLexemeFormMessage, leaf => ConvertLexemeFormItem(leaf, field)); return authority; } @@ -138,5 +171,165 @@ private static bool ShowsPartOf(DetailField field, ICmObject target) } return false; } + + /// + /// The jump item (Show Entry in Concordance and the like) for the row: enabled when + /// resolves a target for the row's object and field, + /// disabled otherwise. With no row, or a row without an object, the shown record is + /// the target, as it is for a tree with no current slice. + /// + internal DetailMenuItem JumpItem(DetailMenuLeaf leaf, DetailField field) + { + var label = XCoreMenuBridge.StripAccelerator(leaf.Label); + var tool = XmlUtils.GetMandatoryAttributeValue(leaf.Command.Parameters[0], "tool"); + var className = XmlUtils.GetMandatoryAttributeValue(leaf.Command.Parameters[0], "className"); + var root = ResolveShownRecord(Clerk?.CurrentObject); + var target = JumpSourceOf(field, root, out var flid); + var jump = DetailJumpTargets.Resolve(tool, className, leaf.CommandId, Guid.Empty, root, target, flid); + if (jump.Guid == Guid.Empty) + return DetailMenuItem.Disabled(label); + return new DetailMenuItem(label, isEnabled: true, isChecked: false, children: null, + execute: () => Jump(tool, jump)); + } + + // The object and field a row's jump resolves from: no row or no object means the + // root with no field; a header row has no field; else the row's object and field. + private ICmObject JumpSourceOf(DetailField field, ICmObject root, out int? flid) + { + flid = null; + if (field == null || field.ObjectHvo == 0) + return root; + if (!Cache.ServiceLocator.ObjectRepository.TryGetObject(field.ObjectHvo, out var target)) + return null; + if (field.Kind != DetailFieldKind.Header) + flid = FieldIdOf(target, field.Field); + return target; + } + + // The field id of the named field on the object's class, 0 when it has none. + private int FieldIdOf(ICmObject target, string fieldName) + { + if (string.IsNullOrEmpty(fieldName)) + return 0; + var mdc = (IFwMetaDataCacheManaged)Cache.MetaDataCacheAccessor; + return mdc.FieldExists(target.ClassID, fieldName, true) ? mdc.GetFieldId2(target.ClassID, fieldName, true) : 0; + } + + // Follows the resolved jump; a notebook jump from a text without a record creates the + // record first (its own unit of work, so pending edits settle before it) and shows that. + private void Jump(string tool, DetailJumpTarget jump) + { + SettleDetailEdits(); + var guid = jump.TextWithoutRecord != null + ? DetailJumpTargets.AssociateWithNotebook(jump.TextWithoutRecord).Guid + : jump.Guid; + FollowLink(new FwLinkArgs(tool, guid)); + } + + /// + /// The Swap Lexeme Form with Allomorph item for the row: absent unless the entry + /// the row's object belongs to (the shown entry with no row) has a lexeme form and + /// allomorphs to swap it with. + /// + internal DetailMenuItem SwapLexemeFormItem(DetailMenuLeaf leaf, DetailField field) + { + var entry = EntryOf(field); + if (!LexemeFormConversion.CanSwap(entry)) + return null; + return new DetailMenuItem(XCoreMenuBridge.StripAccelerator(leaf.Label), isEnabled: true, isChecked: false, + children: null, execute: () => SwapLexemeForm(entry, leaf.Command)); + } + + /// + /// Swaps the entry's lexeme form with the allomorph the user picks: pending edits + /// settle first, the swap is one undoable step, and the view re-shows. + /// + /// True when the forms were swapped. + internal bool SwapLexemeForm(ILexEntry entry, Command command) + { + SettleDetailEdits(); + if (!LexemeFormConversion.CanSwap(entry)) + return false; + var allomorph = (AllomorphToSwap ?? ChooseAllomorphWithDialog)(entry); + if (allomorph == null || !entry.AlternateFormsOS.Contains(allomorph)) + return false; + LexemeFormConversion.Swap(entry, allomorph, command.UndoText, command.RedoText); + OnAvaloniaDetailEditCompleted(this, EventArgs.Empty); + return true; + } + + private IMoForm ChooseAllomorphWithDialog(ILexEntry entry) + { + var mainWindow = m_propertyTable.GetValue("window"); + using (new WaitCursor(mainWindow)) + using (var dlg = new SwapLexemeWithAllomorphDlg()) + { + dlg.SetDlgInfo(Cache, m_propertyTable, entry); + return dlg.ShowDialog(mainWindow) == DialogResult.OK ? dlg.SelectedAllomorph : null; + } + } + + /// + /// The Convert to Affix Process / Convert to Affix Form item for the row: absent + /// unless the row's form is of the class the command converts from. + /// + internal DetailMenuItem ConvertLexemeFormItem(DetailMenuLeaf leaf, DetailField field) + { + var form = FormOf(field); + var entry = form?.OwnerOfClass(); + var toClassId = ClassIdOf(leaf.Command.GetParameter("toClassName")); + if (entry == null || toClassId == 0 + || !LexemeFormConversion.CanConvert(form, ClassIdOf(leaf.Command.GetParameter("fromClassName")))) + { + return null; + } + return new DetailMenuItem(XCoreMenuBridge.StripAccelerator(leaf.Label), isEnabled: true, isChecked: false, + children: null, execute: () => ConvertForm(entry, form, toClassId, leaf.Command)); + } + + /// + /// Converts the form to once the user accepts what the + /// conversion loses: pending edits settle first, the conversion is one undoable step, + /// and the re-show focuses the new form's row. + /// + /// True when the form was converted. + internal bool ConvertForm(ILexEntry entry, IMoForm form, int toClassId, Command command) + { + SettleDetailEdits(); + if (!form.IsValidObject || form.Owner != entry) + return false; + var warning = LexemeFormConversion.DataLossMessage(form); + if (warning != null && !ConversionLossAccepted(warning)) + return false; + var newForm = LexemeFormConversion.Convert(entry, form, toClassId, command.UndoText, command.RedoText); + m_avaloniaEntryForm?.FocusObjectOnNextShow(newForm.Hvo); + OnAvaloniaDetailEditCompleted(this, EventArgs.Empty); + return true; + } + + // The row's object as a form, null when it is none or gone. + private IMoForm FormOf(DetailField field) + { + if (field == null || field.ObjectHvo == 0 || field.GhostPrompt != null) + return null; + return Cache.ServiceLocator.ObjectRepository.TryGetObject(field.ObjectHvo, out var target) ? target as IMoForm : null; + } + + // The entry the row's object belongs to, or the shown entry when the row shows none. + private ILexEntry EntryOf(DetailField field) + { + if (field != null && field.ObjectHvo != 0 + && Cache.ServiceLocator.ObjectRepository.TryGetObject(field.ObjectHvo, out var target)) + { + return target as ILexEntry ?? target.OwnerOfClass(); + } + return ResolveShownRecord(Clerk?.CurrentObject) as ILexEntry; + } + + private int ClassIdOf(string className) + { + var mdc = (IFwMetaDataCacheManaged)Cache.MetaDataCacheAccessor; + return !string.IsNullOrEmpty(className) && mdc.ClassExists(className) ? mdc.GetClassId(className) : 0; + } } } diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.ReferenceVectorMenus.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.ReferenceVectorMenus.cs index 3ba7cb4948..59d75425cf 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.ReferenceVectorMenus.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.ReferenceVectorMenus.cs @@ -283,13 +283,7 @@ void IReferenceItemMenuHost.RunJump(Action jump) } void IReferenceItemMenuHost.FilterByAnthroItem(string tool, int anthroItemHvo) - { - SettleDetailEdits(); -#pragma warning disable 618 // suppress obsolete warning - m_mediator.PostMessage("FollowLink", - AnthroItemFilterLink.Create(Cache.ProjectId.Handle, tool, anthroItemHvo)); -#pragma warning restore 618 - } + => FollowLink(AnthroItemFilterLink.Create(Cache.ProjectId.Handle, tool, anthroItemHvo)); // Model edits of their own: pending edits settle first (a rollback cancels the mark), so // each is its own undo step; the change re-shows through PropChanged. diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs index 7582582843..c5410582c1 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs @@ -927,37 +927,8 @@ public void SubentriesCtrlClick_ResolvesTheClickedEntry_AndRunsTheDefaultJumpPat // Runs an action and returns the link it posts, once the post has been delivered. private FwLinkArgs CaptureFollowLink(Action action) - { - var window = m_propertyTable.GetValue("window"); - var spy = new FollowLinkSpy(); - window.Mediator.AddColleague(spy); - try - { - action(); - DrainMediatorAndIdleQueues(); - return spy.Link; - } - finally - { - window.Mediator.RemoveColleague(spy); - } - } - - // Captures the link a jump posts. It sorts ahead of the window's own link listener, - // which shares the High priority and was registered first, so it sees the link first. - private sealed class FollowLinkSpy : IxCoreColleague - { - public FwLinkArgs Link { get; private set; } - public void Init(Mediator mediator, PropertyTable propertyTable, XmlNode configurationParameters) { } - public IxCoreColleague[] GetMessageTargets() => new IxCoreColleague[] { this }; - public bool ShouldNotCall => false; - public int Priority => (int)ColleaguePriority.High - 1; - public bool OnFollowLink(object args) - { - Link = args as FwLinkArgs; - return true; - } - } + => FollowLinkSpy.Capture(m_propertyTable.GetValue("window").Mediator, action, + DrainMediatorAndIdleQueues); [Test] public void SubentriesMoveRight_ThroughTheHost_ReordersTheRow() @@ -1265,10 +1236,10 @@ public void LabelMenu_IsFullyOwned_OnEveryRowKind() "a multi-string row's menu, writing-system list included, is answered natively"); var lexemeForm = LexemeFormField(); Assert.That(m_view.CreateMultiStringMenuAuthority(lexemeForm).Owns(MultiStringMenuAuthority.MenuId), - Is.True, "the shared multi-string group is owned even on a row whose own menu id is not yet"); + Is.True, "the shared multi-string group is owned on the Lexeme Form row too"); Assert.That(XCoreMenuBridge.OwnsAll(m_view.CreateMenuAuthority(LabelMenuRequest(lexemeForm, NoItem)), - LabelMenuIds(lexemeForm)), Is.False, - "the Lexeme Form row still needs the adapter for mnuDataTree-LexemeForm, not for its writing systems"); + LabelMenuIds(lexemeForm)), Is.True, + "the Lexeme Form row's menu, its own jump, swap and convert leaves included, is answered natively"); Assert.That(m_view.CreateMultiStringMenuAuthority(subentries).Owns(MultiStringMenuAuthority.MenuId), Is.False, "a row that is not multi-string never claims the multi-string menu"); Assert.That(XCoreMenuBridge.OwnsAll(null, new[] { ReorderVectorMenuAuthority.MenuId }), Is.False); diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/FollowLinkSpy.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/FollowLinkSpy.cs new file mode 100644 index 0000000000..d785e73ec9 --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/FollowLinkSpy.cs @@ -0,0 +1,49 @@ +// 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.Xml; +using SIL.FieldWorks.Common.FwUtils; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Captures the link a jump posts. It sorts ahead of the window's own link listener, + /// which shares the High priority and was registered first, so it sees the link first. + /// + internal sealed class FollowLinkSpy : IxCoreColleague + { + public FwLinkArgs Link { get; private set; } + public void Init(Mediator mediator, PropertyTable propertyTable, XmlNode configurationParameters) { } + public IxCoreColleague[] GetMessageTargets() => new IxCoreColleague[] { this }; + public bool ShouldNotCall => false; + public int Priority => (int)ColleaguePriority.High - 1; + public bool OnFollowLink(object args) + { + Link = args as FwLinkArgs; + return true; + } + + /// + /// Runs and returns the link it posts; null when it posts none. + /// + /// Delivers the posted messages, so the spy sees the post. + public static FwLinkArgs Capture(Mediator mediator, Action action, Action deliver) + { + var spy = new FollowLinkSpy(); + mediator.AddColleague(spy); + try + { + action(); + deliver(); + return spy.Link; + } + finally + { + mediator.RemoveColleague(spy); + } + } + } +} diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs index 9e35741a64..7331f0b067 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs @@ -32,12 +32,16 @@ namespace SIL.FieldWorks.XWorks [Apartment(System.Threading.ApartmentState.STA)] public class LexiconMenuAuthorityTests : XWorksAppTestBase { - // The ids owned today: every leaf of each sends DataTreeDelete. Ownership is by message - // over the whole window configuration, so other areas' ids and unbound ids count too. + // The ids owned today: every leaf of each sends an answered message. Ownership is by + // message over the whole configuration, so other areas' ids and unbound ids count too. private static readonly string[] PinnedOwnedIds = { // Lexicon rows: Translation, and Complex Form Type on a complex form. "mnuDataTree-Translation", "mnuDataTree-ComplexFormSpec", + // Lexicon rows: the Lexeme Form row's label menu and the in-string menus of the + // Lexeme Form, Citation Form and variant form rows. + "mnuDataTree-LexemeForm", "mnuDataTree-LexemeFormContext", "mnuDataTree-CitationFormContext", + "mnuDataTree-VariantFormContext", // Lexicon ids no part reaches (-CmMedia's pronunciation part does not exist). "mnuDataTree-CmMedia", "mnuDataTree-Variant", "mnuDataTree-DeleteEtymology", "mnuDataTree-DeletePronunciation", @@ -47,12 +51,22 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase "mnuDataTree-ClosedFeature-Value", "mnuDataTree-MoStemName-Region", "mnuDataTree-Phoneme-Code", "mnuDataTree-Delete-Adhoc-Allomorph", "mnuDataTree-Delete-Adhoc-Group", "mnuDataTree-Delete-Adhoc-Morpheme", + // Texts: the Info tab's jumps to the interlinear text and its notebook record; no + // Avalonia detail view. + "mnuDataTree-Text", "mnuTextInfo-Notebook", // Lists: no Avalonia detail view. "mnuDataTree-DeleteCustomItem", "mnuDataTree-DeletePossibility", "mnuDataTree-DeleteQuestion" }; - // The ids a Lexicon Edit row raises, the rows the contracts below are proved on. - private static readonly string[] LexiconRowIds = { "mnuDataTree-Translation", "mnuDataTree-ComplexFormSpec" }; + // The owned ids a Lexicon Edit row binds as its label menu and as its in-string menu: + // the rows the contracts below are proved on. + private static readonly string[] LexiconLabelMenuIds = + { "mnuDataTree-Translation", "mnuDataTree-ComplexFormSpec", "mnuDataTree-LexemeForm" }; + + private static readonly string[] LexiconInStringMenuIds = + { "mnuDataTree-LexemeFormContext", "mnuDataTree-CitationFormContext", "mnuDataTree-VariantFormContext" }; + + private const string LexemeFormContextMenuId = "mnuDataTree-LexemeFormContext"; // The messages of mnuDataTree-Object's six leaves, the test subject for the predicate. private static readonly string[] ObjectMenuMessages = @@ -64,6 +78,8 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase private PropertyTable m_propertyTable; private List m_createdObjects; private ILexEntry m_entry; + private IMoStemAllomorph m_allomorph; + private ILexEntry m_variant; private ICmTranslation m_translation; private RecordEditView m_view; @@ -208,55 +224,56 @@ public void Build_RejectsALeafWhoseMessageHasNoAnswerer_AndEveryList() MultiStringMenuAuthority.WritingSystemListId), Throws.InvalidOperationException); } - // Contract: on every Lexicon Edit row binding an owned id, the owned menu renders what - // the adapter path rendered; the row's other groups are proved by their own authorities. + // Contract: on every Lexicon Edit row binding an owned id (label or in-string menu), + // the owned menu renders what the adapter path rendered. [Test] public void OwnedMenu_RendersWhatTheAdapterPathRendered_OnEveryLexiconRowThatBindsOne() { - var rows = DetailComposer.Compose(m_entry, Cache).Model.Fields - .Where(f => LexiconRowIds.Contains(f.MenuId)).ToList(); - Assert.That(rows.Select(f => f.Label), Is.EquivalentTo(new[] { "Translations 1", "Translation", "Complex Form Type" }), - "precondition: the entry composes the rows the owned ids are bound to"); + var rows = OwnedMenuRows(); + Assert.That(rows.Select(r => r.MenuId).Distinct(), Is.EquivalentTo( + LexiconLabelMenuIds.Concat(LexiconInStringMenuIds)), + "precondition: the entry composes a row for every owned id"); var mismatches = new List(); - foreach (var row in rows) + foreach (var (row, menuId, kind) in rows) { EnsureAdapter(row.ObjectHvo, row.Field); Assert.That(AdapterTree.CurrentSlice?.Object?.Hvo, Is.EqualTo(row.ObjectHvo), "precondition: the adapter targets the row's object for " + row.Label); - var ids = new[] { row.MenuId }; + var ids = new[] { menuId }; var before = Describe(XCoreMenuBridge.CreateMenuItems(Window, ids, null, null, null)); var after = Describe(XCoreMenuBridge.CreateMenuItems(Window, ids, null, null, - m_view.CreateMenuAuthority(LabelMenuRequest(row)))); + m_view.CreateMenuAuthority(MenuRequest(row, kind)))); Assert.That(after, Does.Contain("enabled=True checked=False executes=True"), - row.Label + ": Delete is offered and runs"); + row.Label + ": " + menuId + " offers a command that runs"); if (!string.Equals(before, after, StringComparison.Ordinal)) { mismatches.Add(string.Format("{0} ({1}):{2}--- adapter path{2}{3}{2}--- authority{2}{4}", - row.Label, row.Field, Environment.NewLine, before, after)); + row.Label, menuId, Environment.NewLine, before, after)); } } Assert.That(mismatches, Is.Empty, string.Join(Environment.NewLine, mismatches)); } - // Contract: an owned label menu never asks the mediator and never builds the adapter. + // Contract: an owned menu never asks the mediator and never builds the adapter. [Test] - public void OwnedLabelMenus_AreBuiltWithoutTheAdapterOrTheMediator() + public void OwnedMenus_AreBuiltWithoutTheAdapterOrTheMediator() { Assert.That(HiddenTreeExists, Is.False, "precondition: no hidden tree yet"); - var rows = DetailComposer.Compose(m_entry, Cache).Model.Fields - .Where(f => LexiconRowIds.Contains(f.MenuId)).ToList(); - Assert.That(rows, Has.Count.EqualTo(3), "precondition"); - foreach (var row in rows) + var rows = OwnedMenuRows(); + Assert.That(rows, Has.Count.EqualTo(7), "precondition: six ids, -Translation on two rows"); + foreach (var (row, menuId, kind) in rows) { - var ids = RecordEditView.ComposeSliceMenuIds(row.MenuId, row.IsMultiStringRow).ToArray(); - var authority = m_view.CreateMenuAuthority(LabelMenuRequest(row)); - Assert.That(XCoreMenuBridge.OwnsAll(authority, ids), Is.True, row.Label + ": every id of the label menu is owned"); + var ids = kind == DetailMenuKind.ContextMenu + ? RecordEditView.ComposeInStringMenuIds(menuId).ToArray() + : RecordEditView.ComposeSliceMenuIds(menuId, row.IsMultiStringRow).ToArray(); + var authority = m_view.CreateMenuAuthority(MenuRequest(row, kind)); + Assert.That(XCoreMenuBridge.OwnsAll(authority, ids), Is.True, row.Label + ": every id of the menu is owned"); var items = BuildWithSpy(ids, authority, out var asked); Assert.That(asked, Is.False, row.Label + ": the mediator was asked"); - Assert.That(items.Any(i => i.Label.StartsWith("Delete", StringComparison.Ordinal)), Is.True); + Assert.That(items.Any(i => i.Execute != null), Is.True, row.Label + ": " + menuId + " offers a command"); } Assert.That(HiddenTreeExists, Is.False, "an owned menu never builds the hidden tree"); } @@ -307,6 +324,54 @@ public void DeleteLeaf_IsDisabled_WhenTheObjectIsGone_AndWhenTheRuleRefuses() Assert.That(refused.Label, Does.EndWith(note)); } + // A jump is enabled only on a row the rule resolves a target for; with no row the + // shown entry stands in. The adapter path showed a targetless jump enabled and + // inert; natively it is disabled. + [Test] + public void JumpLeaf_IsEnabledOnlyWhenTheRowOffersATarget() + { + var formJump = Leaf(LexemeFormContextMenuId, "CmdLexemeFormJumpToConcordance"); + var entryJump = Leaf(LexemeFormContextMenuId, "CmdEntryJumpToConcordance"); + var lexemeForm = LexemeFormRow(); + var citationForm = Rows().Single(f => f.Field == "CitationForm" && f.ObjectHvo == m_entry.Hvo); + + Assert.That(m_view.JumpItem(formJump, lexemeForm).IsEnabled, Is.True, "the Form field of the lexeme form"); + Assert.That(m_view.JumpItem(formJump, citationForm).IsEnabled, Is.False, "the entry is no form"); + Assert.That(m_view.JumpItem(formJump, null).IsEnabled, Is.False, "no row: the shown entry is no form"); + Assert.That(m_view.JumpItem(entryJump, citationForm).IsEnabled, Is.True); + Assert.That(m_view.JumpItem(entryJump, lexemeForm).IsEnabled, Is.True, "the entry owning the form"); + Assert.That(m_view.JumpItem(entryJump, null).IsEnabled, Is.True, "no row: the shown entry"); + Assert.That(m_view.JumpItem(entryJump, null).Label, Is.EqualTo("Show Entry in Concordance")); + } + + [Test] + public void SwapLeaf_IsAbsentWithoutAnAllomorph_AndConvertLeaves_FollowTheFormsClass() + { + var swap = Leaf("mnuDataTree-LexemeForm", "CmdDataTree-Swap-LexemeForm"); + var toProcess = Leaf("mnuDataTree-LexemeForm", "CmdDataTree-Convert-LexemeForm-AffixProcess"); + var toAffixForm = Leaf("mnuDataTree-LexemeForm", "CmdDataTree-Convert-LexemeForm-AffixAllomorph"); + var lexemeForm = LexemeFormRow(); + + Assert.That(m_view.SwapLexemeFormItem(swap, lexemeForm)?.IsEnabled, Is.True, "the entry has an allomorph"); + Assert.That(m_view.SwapLexemeFormItem(swap, lexemeForm).Label, Is.EqualTo("Swap Lexeme Form with Allomorph...")); + Assert.That(m_view.SwapLexemeFormItem(swap, null)?.IsEnabled, Is.True, "no row: the shown entry"); + Assert.That(m_view.ConvertLexemeFormItem(toProcess, lexemeForm), Is.Null, "a stem converts to nothing"); + Assert.That(m_view.ConvertLexemeFormItem(toAffixForm, lexemeForm), Is.Null); + Assert.That(m_view.ConvertLexemeFormItem(toProcess, null), Is.Null, "no row: no form"); + + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry.AlternateFormsOS.Clear(); + m_entry.LexemeFormOA = Cache.ServiceLocator.GetInstance().Create(); + }); + var affixForm = LexemeFormRow(); + + Assert.That(m_view.SwapLexemeFormItem(swap, affixForm), Is.Null, "nothing to swap with"); + Assert.That(m_view.ConvertLexemeFormItem(toProcess, affixForm)?.IsEnabled, Is.True, "an affix form converts to a process"); + Assert.That(m_view.ConvertLexemeFormItem(toProcess, affixForm).Label, Is.EqualTo("Convert to Affix Process")); + Assert.That(m_view.ConvertLexemeFormItem(toAffixForm, affixForm), Is.Null, "it already is one"); + } + private XWindow Window => m_propertyTable.GetValue("window"); private bool HiddenTreeExists => (bool)GetField(m_view, "m_dataTreeInitialized"); @@ -322,16 +387,34 @@ private LexiconMenuAuthority NewAuthority() private static DetailMenuItem Echo(DetailMenuLeaf leaf) => new DetailMenuItem(XCoreMenuBridge.StripAccelerator(leaf.Label), isEnabled: true); - private static DetailMenuRequest LabelMenuRequest(DetailField field) - => DetailMenuRequest.FromAnchor(null, field, DetailMenuKind.SliceMenu); + private static DetailMenuRequest MenuRequest(DetailField field, DetailMenuKind kind) + => DetailMenuRequest.FromAnchor(null, field, kind); + + private IReadOnlyList Rows() => DetailComposer.Compose(m_entry, Cache).Model.Fields; private DetailField TranslationRow() - => DetailComposer.Compose(m_entry, Cache).Model.Fields - .Single(f => f.Field == "Translation" && f.ObjectHvo == m_translation.Hvo); + => Rows().Single(f => f.Field == "Translation" && f.ObjectHvo == m_translation.Hvo); + + private DetailField LexemeFormRow() => Rows().Single(f => f.MenuId == "mnuDataTree-LexemeForm"); + + // Every (row, owned id, menu kind) the entry's rows bind: the label menus first, then + // the in-string menus. + private List<(DetailField Row, string MenuId, DetailMenuKind Kind)> OwnedMenuRows() + { + var rows = Rows(); + var bound = rows.Where(f => LexiconLabelMenuIds.Contains(f.MenuId)) + .Select(f => (f, f.MenuId, DetailMenuKind.SliceMenu)); + bound = bound.Concat(rows.Where(f => LexiconInStringMenuIds.Contains(f.ContextMenuId)) + .Select(f => (f, f.ContextMenuId, DetailMenuKind.ContextMenu))); + return bound.ToList(); + } private DetailMenuLeaf DeleteTranslationLeaf() => XCoreMenuBridge.ResolveMenu(Window, "mnuDataTree-Translation").Leaves.Single(); + private DetailMenuLeaf Leaf(string menuId, string commandId) + => XCoreMenuBridge.ResolveMenu(Window, menuId).Leaves.Single(l => l.CommandId == commandId); + // Every context-menu id the window configuration defines, each once. private IEnumerable ContextMenuIds() { @@ -377,6 +460,21 @@ public bool OnDisplayShowFieldAlwaysVisible(object commandObject, ref UIItemDisp Asked = true; return false; } + public bool OnDisplayJumpToTool(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } + public bool OnDisplaySwapLexemeWithAllomorph(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } + public bool OnDisplayConvertLexemeForm(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } } private static string Describe(IReadOnlyList items, string indent = "") @@ -405,13 +503,17 @@ private void EnsureAdapter(int targetHvo, string fieldName) method.Invoke(m_view, new object[] { targetHvo, fieldName }); } - // An entry with a sense whose example has a free translation, made a complex form of - // another entry so its own LexEntryRef shows the Complex Form Type row. + // An entry with an allomorph, a sense whose example has a free translation, a + // spelling variant, and made a complex form of another entry so its own LexEntryRef + // shows the Complex Form Type row. private void CreateTestEntry() { var stemMorphType = GetMorphTypeOrCreateOne("stem"); var noun = GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA); m_entry = AddLexeme(m_createdObjects, "command-entry", stemMorphType, "first gloss", noun); + 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)); 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)); @@ -428,6 +530,14 @@ private void CreateTestEntry() complexFormRef.PrimaryLexemesRS.Add(component); complexFormRef.ComplexEntryTypesRS.Add(Cache.ServiceLocator.GetInstance() .GetObject(LexEntryTypeTags.kguidLexTypCompound)); + + m_variant = AddLexeme(m_createdObjects, "command-variant", stemMorphType, "variant gloss", noun); + var variantRef = Cache.ServiceLocator.GetInstance().Create(); + m_variant.EntryRefsOS.Add(variantRef); + variantRef.RefType = LexEntryRefTags.krtVariant; + variantRef.ComponentLexemesRS.Add(m_entry); + variantRef.VariantEntryTypesRS.Add(Cache.ServiceLocator.GetInstance() + .GetObject(LexEntryTypeTags.kguidLexTypSpellingVar)); } private void LoadRecordEditView(string toolValue) diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs index c0e27f6a6f..a121859c82 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs @@ -2,6 +2,7 @@ // 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.IO; using System.Linq; @@ -22,15 +23,22 @@ namespace SIL.FieldWorks.XWorks /// End-to-end proof that the Lexicon menu commands the native authority answers change /// the model through the product : the item's own Execute runs /// the host, which asks for confirmation, edits in one undoable step and tells the re-show - /// where to put the keyboard. + /// where to put the keyboard. Jumps post the link the view follows. /// [TestFixture] [Apartment(System.Threading.ApartmentState.STA)] public class LexiconMenuExecutionTests : XWorksAppTestBase { + private const string LexemeFormMenuId = "mnuDataTree-LexemeForm"; + private const string VariantFormContextMenuId = "mnuDataTree-VariantFormContext"; + private PropertyTable m_propertyTable; private List m_createdObjects; private ILexEntry m_entry; + private IMoStemAllomorph m_allomorph; + private ILexEntry m_variant; + private ILexEntry m_affix; + private SIL.LCModel.IText m_text; private ILexExampleSentence m_example; private ICmTranslation m_translation; private ILexEntryRef m_complexFormRef; @@ -102,12 +110,12 @@ public void DeleteTranslation_RemovesIt_InOneUndoableStep_WhenTheUserConfirms() }; var translationHvo = m_translation.Hvo; - InvokeDelete(TranslationRow(), "Delete Translation"); + InvokeLabelMenuItem(TranslationRow(), "Delete Translation"); Assert.That(confirmed, Is.EqualTo(1)); Assert.That(Cache.ServiceLocator.ObjectRepository.IsValidObjectId(translationHvo), Is.False, "the translation is gone"); Assert.That(m_example.TranslationsOC, Is.Empty); - Assert.That(DetailComposer.Compose(m_entry, Cache).Model.Fields.Any(f => f.ObjectHvo == translationHvo), Is.False, + Assert.That(Rows(m_entry).Any(f => f.ObjectHvo == translationHvo), Is.False, "the recomposed detail view has no row for it"); Assert.That(Cache.ActionHandlerAccessor.CanUndo(), Is.True); @@ -120,7 +128,7 @@ public void DeleteTranslation_LeavesTheModelAlone_WhenTheUserDeclines() { m_view.DeleteConfirmation = ui => false; - InvokeDelete(TranslationRow(), "Delete Translation"); + InvokeLabelMenuItem(TranslationRow(), "Delete Translation"); Assert.That(m_translation.IsValidObject, Is.True); Assert.That(m_example.TranslationsOC, Has.Count.EqualTo(1)); @@ -130,11 +138,10 @@ public void DeleteTranslation_LeavesTheModelAlone_WhenTheUserDeclines() public void DeleteComplexFormInfo_RemovesTheEntryRef() { m_view.DeleteConfirmation = ui => true; - var row = DetailComposer.Compose(m_entry, Cache).Model.Fields - .Single(f => f.MenuId == "mnuDataTree-ComplexFormSpec"); + var row = Rows(m_entry).Single(f => f.MenuId == "mnuDataTree-ComplexFormSpec"); Assert.That(row.ObjectHvo, Is.EqualTo(m_complexFormRef.Hvo), "precondition: the row shows the entry's own ref"); - InvokeDelete(row, "Delete Complex Form Info"); + InvokeLabelMenuItem(row, "Delete Complex Form Info"); Assert.That(m_entry.EntryRefsOS, Is.Empty); Assert.That(m_entry.IsValidObject, Is.True, "only the ref goes, not the entry"); @@ -167,12 +174,148 @@ public void RowsToFocusAfterDeleting_AreTheNeighbours_BeforeThenAfter_NearestFir Assert.That(RecordEditView.RowsToFocusAfterDeleting(null, m_translation), Is.Empty); } + [Test] + public void SwapLexemeForm_MakesThePickedAllomorphTheLexemeForm_InOneUndoableStep() + { + var oldLexemeForm = m_entry.LexemeFormOA; + ILexEntry asked = null; + m_view.AllomorphToSwap = entry => + { + asked = entry; + return m_allomorph; + }; + + InvokeLabelMenuItem(LexemeFormRow(m_entry), "Swap Lexeme Form with Allomorph..."); + + Assert.That(asked, Is.SameAs(m_entry), "the user picks among the shown entry's allomorphs"); + 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 row shows the new lexeme form"); + + Assert.That(Cache.ActionHandlerAccessor.CanUndo(), Is.True); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_entry.LexemeFormOA, Is.SameAs(oldLexemeForm), "one Undo swaps them back"); + Assert.That(m_entry.AlternateFormsOS, Is.EqualTo(new IMoForm[] { m_allomorph })); + } + + [Test] + public void SwapLexemeForm_LeavesTheModelAlone_WhenTheUserCancels() + { + var oldLexemeForm = m_entry.LexemeFormOA; + m_view.AllomorphToSwap = entry => null; + + InvokeLabelMenuItem(LexemeFormRow(m_entry), "Swap Lexeme Form with Allomorph..."); + + Assert.That(m_entry.LexemeFormOA, Is.SameAs(oldLexemeForm)); + Assert.That(m_entry.AlternateFormsOS, Is.EqualTo(new IMoForm[] { m_allomorph })); + } + + [Test] + public void ConvertLexemeForm_ReplacesTheForm_WithoutAskingWhenNothingIsLost() + { + ShowEntry(m_affix); + var oldForm = m_affix.LexemeFormOA; + m_view.ConversionLossAccepted = warning => throw new InvalidOperationException("nothing is lost: no question"); + + InvokeLabelMenuItem(LexemeFormRow(m_affix), "Convert to Affix Process"); + + Assert.That(m_affix.LexemeFormOA, Is.InstanceOf()); + Assert.That(m_affix.LexemeFormOA.Form.get_String(Cache.DefaultVernWs).Text, Is.EqualTo("ing"), "the form text carries over"); + Assert.That(oldForm.IsValidObject, Is.False); + Assert.That(LexemeFormRow(m_affix).ObjectHvo, Is.EqualTo(m_affix.LexemeFormOA.Hvo), "the recomposed row shows the new form"); + Assert.That(Cache.ActionHandlerAccessor.CanUndo(), Is.True); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_affix.LexemeFormOA, Is.InstanceOf(), "one Undo restores the old form"); + } + + [Test] + public void ConvertLexemeForm_AsksAboutTheLoss_AndConvertsOnlyWhenTheUserAccepts() + { + ShowEntry(m_affix); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var environment = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.PhonologicalDataOA.EnvironmentsOS.Add(environment); + ((IMoAffixAllomorph)m_affix.LexemeFormOA).PhoneEnvRC.Add(environment); + }); + var warnings = new List(); + m_view.ConversionLossAccepted = warning => + { + warnings.Add(warning); + return false; + }; + + InvokeLabelMenuItem(LexemeFormRow(m_affix), "Convert to Affix Process"); + + Assert.That(warnings, Is.EqualTo(new[] { "The environment will be lost! Do you still want to continue?" })); + Assert.That(m_affix.LexemeFormOA, Is.InstanceOf(), "declined: nothing changes"); + + m_view.ConversionLossAccepted = warning => true; + InvokeLabelMenuItem(LexemeFormRow(m_affix), "Convert to Affix Process"); + + Assert.That(m_affix.LexemeFormOA, Is.InstanceOf(), "accepted: the form converts"); + } + + [Test] + public void InStringJumps_FollowTheLinkTheRowResolves() + { + var lexemeForm = LexemeFormRow(m_entry); + var variantForm = Rows(m_entry).Single(f => f.ContextMenuId == VariantFormContextMenuId); + Assert.That(variantForm.ObjectHvo, Is.EqualTo(m_variant.LexemeFormOA.Hvo), "precondition: the variant's form row"); + + var toConcordance = CaptureFollowLink(() => InvokeInStringMenuItem(lexemeForm, "Show Lexeme Form in Concordance")); + Assert.That(toConcordance?.ToolName, Is.EqualTo("concordance")); + Assert.That(toConcordance.TargetGuid, Is.EqualTo(m_entry.LexemeFormOA.Guid)); + + var toEntry = CaptureFollowLink(() => InvokeInStringMenuItem(lexemeForm, "Show Entry in Concordance")); + Assert.That(toEntry?.TargetGuid, Is.EqualTo(m_entry.Guid), "the entry owning the form"); + + var toVariant = CaptureFollowLink(() => InvokeInStringMenuItem(variantForm, "Show Entry in Lexicon")); + Assert.That(toVariant?.ToolName, Is.EqualTo("lexiconEdit")); + Assert.That(toVariant.TargetGuid, Is.EqualTo(m_variant.Guid), "the variant entry, not the shown one"); + } + + // The one jump that writes: a text without a notebook record gets one, associated with + // it, and the jump shows that record. A second jump reuses it. + [Test] + public void NotebookJump_FromATextWithoutARecord_CreatesTheRecord_AndShowsIt() + { + var leaf = XCoreMenuBridge.ResolveMenu(m_propertyTable.GetValue("window"), "mnuTextInfo-Notebook") + .Leaves.Single(); + var row = new DetailField("Text/notebook@" + m_text.Hvo, "Notebook Record", null, null, DetailFieldKind.Header, + EditorClassification.GroupingNone, null, null, HostRouting.Inherit, null, null, null, + menuId: "mnuTextInfo-Notebook", objectHvo: m_text.Hvo); + Assert.That(m_text.AssociatedNotebookRecord, Is.Null, "precondition: the text has no record"); + var item = m_view.JumpItem(leaf, row); + Assert.That(item.Execute, Is.Not.Null, "the jump is offered before the record exists"); + + var link = CaptureFollowLink(item.Execute); + + var record = m_text.AssociatedNotebookRecord; + Assert.That(record, Is.Not.Null, "the jump created the record and associated it with the text"); + m_createdObjects.Add(record); + Assert.That(link?.ToolName, Is.EqualTo("notebookEdit")); + Assert.That(link.TargetGuid, Is.EqualTo(record.Guid), "the jump shows the new record, not the text"); + + var again = CaptureFollowLink(m_view.JumpItem(leaf, row).Execute); + Assert.That(m_text.AssociatedNotebookRecord, Is.SameAs(record), "no second record"); + Assert.That(again?.TargetGuid, Is.EqualTo(record.Guid)); + } + // Builds the row's label menu as the host does and runs the named item's own Execute. - private void InvokeDelete(DetailField row, string label) + private void InvokeLabelMenuItem(DetailField row, string label) + => Invoke(RecordEditView.ComposeSliceMenuIds(row.MenuId, row.IsMultiStringRow).ToArray(), row, + DetailMenuKind.SliceMenu, label); + + // Builds the row's in-string menu as the host does and runs the named item's own Execute. + private void InvokeInStringMenuItem(DetailField row, string label) + => Invoke(RecordEditView.ComposeInStringMenuIds(row.ContextMenuId).ToArray(), row, + DetailMenuKind.ContextMenu, label); + + private void Invoke(string[] ids, DetailField row, DetailMenuKind kind, string label) { - var ids = RecordEditView.ComposeSliceMenuIds(row.MenuId, row.IsMultiStringRow).ToArray(); var window = m_propertyTable.GetValue("window"); - var request = DetailMenuRequest.FromAnchor(null, row, DetailMenuKind.SliceMenu); + var request = DetailMenuRequest.FromAnchor(null, row, kind); var items = XCoreMenuBridge.CreateMenuItems(window, ids, null, null, m_view.CreateMenuAuthority(request)); var item = items.FirstOrDefault(i => i.Label == label); Assert.That(item, Is.Not.Null, "the menu offers '{0}'", label); @@ -181,15 +324,35 @@ private void InvokeDelete(DetailField row, string label) DrainMediatorAndIdleQueues(); } + // Runs an action and returns the link it posts, once the post has been delivered. + private FwLinkArgs CaptureFollowLink(Action action) + => FollowLinkSpy.Capture(m_propertyTable.GetValue("window").Mediator, action, + DrainMediatorAndIdleQueues); + + private void ShowEntry(ILexEntry entry) + { + m_view.Clerk.JumpToRecord(entry.Hvo); + DrainMediatorAndIdleQueues(); + } + + private IReadOnlyList Rows(ILexEntry entry) => DetailComposer.Compose(entry, Cache).Model.Fields; + private DetailField TranslationRow() - => DetailComposer.Compose(m_entry, Cache).Model.Fields - .Single(f => f.Field == "Translation" && f.ObjectHvo == m_translation.Hvo); + => 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); + + // A stem entry with an allomorph, a sense whose example has a free translation, a + // spelling variant, and a complex-form ref; plus a suffix entry to convert and a text + // without a notebook record. private void CreateTestEntry() { var stemMorphType = GetMorphTypeOrCreateOne("stem"); var noun = GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA); m_entry = AddLexeme(m_createdObjects, "command-entry", stemMorphType, "first gloss", noun); + 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_example = Cache.ServiceLocator.GetInstance().Create(); m_entry.SensesOS[0].ExamplesOS.Add(m_example); m_example.Example.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("una casa", Cache.DefaultVernWs)); @@ -206,6 +369,20 @@ private void CreateTestEntry() m_complexFormRef.PrimaryLexemesRS.Add(component); m_complexFormRef.ComplexEntryTypesRS.Add(Cache.ServiceLocator.GetInstance() .GetObject(LexEntryTypeTags.kguidLexTypCompound)); + + m_variant = AddLexeme(m_createdObjects, "command-variant", stemMorphType, "variant gloss", noun); + var variantRef = Cache.ServiceLocator.GetInstance().Create(); + m_variant.EntryRefsOS.Add(variantRef); + variantRef.RefType = LexEntryRefTags.krtVariant; + variantRef.ComponentLexemesRS.Add(m_entry); + variantRef.VariantEntryTypesRS.Add(Cache.ServiceLocator.GetInstance() + .GetObject(LexEntryTypeTags.kguidLexTypSpellingVar)); + + 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_text = Cache.ServiceLocator.GetInstance().Create(); + m_createdObjects.Add(m_text); } private void LoadRecordEditView(string toolValue)