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