From 356ebdb09a96a99155450f2aee4aa3a8291297e2 Mon Sep 17 00:00:00 2001 From: mark-sil <83427558+mark-sil@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:10:39 -0400 Subject: [PATCH] LT-22691: Answer the Lexeme Form jump, swap and convert leaves natively The Lexicon menu authority now answers JumpToTool, SwapLexemeWithAllomorph and ConvertLexemeForm from the row itself. The Lexeme Form row's label menu and the in-string menus of the Lexeme Form, Citation Form and variant form rows (plus the two Texts Info jump menus, owned by the same predicate) build and run without the hidden WinForms DataTree or the mediator. Both detail views share the rules: The WinForms DataTree and LexEntryMenuHandler call the same code, so the two views cannot drift. SwapLexemeWithAllomorphDlg moves to DetailControls, where xWorks can reach it, and its strings and the conversion warnings move to the resources of the projects that now own them. Co-Authored-By: Claude Fable 5.1 --- .../Controls/DetailControls/DataTree.cs | 118 +--------- .../DetailControlsStrings.Designer.cs | 27 +++ .../Resources/DetailControlsStrings.resx | 9 + .../SwapLexemeWithAllomorphDlg.cs | 9 +- .../SwapLexemeWithAllomorphDlg.resx | 0 Src/Common/FwAvalonia/Detail/DataTree.cs | 18 ++ .../FwAvalonia/Detail/DetailFocusMemory.cs | 22 +- Src/Common/FwAvalonia/DetailHostControl.cs | 6 + .../FwAvaloniaTests/DetailFocusMemoryTests.cs | 42 ++++ Src/FdoUi/DetailRules/DetailJumpTargets.cs | 143 +++++++++++++ Src/FdoUi/DetailRules/LexemeFormConversion.cs | 124 +++++++++++ Src/FdoUi/FdoUiStrings.Designer.cs | 81 +++++++ Src/FdoUi/FdoUiStrings.resx | 27 +++ .../DetailRules/DetailJumpTargetsTests.cs | 175 +++++++++++++++ .../DetailRules/LexemeFormConversionTests.cs | 184 ++++++++++++++++ Src/LexText/Lexicon/LexEdStrings.Designer.cs | 108 ---------- Src/LexText/Lexicon/LexEdStrings.resx | 36 ---- Src/LexText/Lexicon/LexEntryMenuHandler.cs | 119 +++-------- .../Hosting/RecordEditView.Avalonia.cs | 26 ++- .../Hosting/RecordEditView.LexiconMenus.cs | 193 +++++++++++++++++ .../RecordEditView.ReferenceVectorMenus.cs | 8 +- .../DetailObjectCommandExecutionTests.cs | 39 +--- .../Avalonia/Hosting/FollowLinkSpy.cs | 49 +++++ .../Hosting/LexiconMenuAuthorityTests.cs | 172 ++++++++++++--- .../Hosting/LexiconMenuExecutionTests.cs | 201 ++++++++++++++++-- 25 files changed, 1486 insertions(+), 450 deletions(-) rename Src/{LexText/Lexicon => Common/Controls/DetailControls}/SwapLexemeWithAllomorphDlg.cs (97%) rename Src/{LexText/Lexicon => Common/Controls/DetailControls}/SwapLexemeWithAllomorphDlg.resx (100%) create mode 100644 Src/FdoUi/DetailRules/DetailJumpTargets.cs create mode 100644 Src/FdoUi/DetailRules/LexemeFormConversion.cs create mode 100644 Src/FdoUi/FdoUiTests/DetailRules/DetailJumpTargetsTests.cs create mode 100644 Src/FdoUi/FdoUiTests/DetailRules/LexemeFormConversionTests.cs create mode 100644 Src/xWorks/xWorksTests/Avalonia/Hosting/FollowLinkSpy.cs 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)