diff --git a/Src/Common/Controls/DetailControls/Slice.cs b/Src/Common/Controls/DetailControls/Slice.cs
index bae37ac5fc..a364d0f9c3 100644
--- a/Src/Common/Controls/DetailControls/Slice.cs
+++ b/Src/Common/Controls/DetailControls/Slice.cs
@@ -2465,14 +2465,11 @@ public virtual void HandleCopyCommand(Slice newSlice, string label)
if (newObj == null)
throw new ConfigurationException("NewSlice:GetObjectHvoForMenusToOperateOn is either messed up or should not have been called, because it could not find the object to be moved to a copy of its owner.", m_configurationNode);
- if (origObj is ICloneableCmObject)
+ if (DetailObjectDuplication.CanCopy(origObj))
{
string undoMsg = String.Format("Undo {0}", label);
string redoMsg = String.Format("Redo {0}", label);
- UndoableUnitOfWorkHelper.Do(undoMsg, redoMsg, m_cache.ActionHandlerAccessor,
- () => {
- ((ICloneableCmObject)origObj).SetCloneProperties(newObj);
- });
+ DetailObjectDuplication.CopyProperties(origObj, newObj, undoMsg, redoMsg);
}
else
{
diff --git a/Src/FdoUi/DetailRules/CategoryItemMove.cs b/Src/FdoUi/DetailRules/CategoryItemMove.cs
new file mode 100644
index 0000000000..3b458a4145
--- /dev/null
+++ b/Src/FdoUi/DetailRules/CategoryItemMove.cs
@@ -0,0 +1,91 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System.Linq;
+using 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 Move command of an affix slot or an affix template (message DataTreeMove): the
+ /// category that holds it, and its move to another category.
+ ///
+ public static class CategoryItemMove
+ {
+ ///
+ /// The category that owns when it is an affix slot or an
+ /// affix template; null for anything else.
+ ///
+ public static IPartOfSpeech CategoryOf(ICmObject target)
+ {
+ if (target == null || !target.IsValidObject)
+ return null;
+ return target is IMoInflAffixSlot || target is IMoInflAffixTemplate ? target.Owner as IPartOfSpeech : null;
+ }
+
+ ///
+ /// Moves the slot or template to in one undoable step.
+ /// The affixes of a moved slot take the new category. A template moved to the category
+ /// that owns its own takes along its slots from its old category, except a slot whose
+ /// name the new category or one above it already uses.
+ ///
+ /// True when it moved; false for the category it is already in, and for an
+ /// object that is no slot or template.
+ public static bool Move(ICmObject target, IPartOfSpeech destination, string undoText, string redoText)
+ {
+ var from = CategoryOf(target);
+ if (from == null || destination == null || !destination.IsValidObject || from == destination)
+ return false;
+ UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW(undoText, redoText, target.Cache.ActionHandlerAccessor, () =>
+ {
+ if (target is IMoInflAffixSlot slot)
+ MoveSlot(slot, destination);
+ else
+ MoveTemplate((IMoInflAffixTemplate)target, from, destination);
+ });
+ return true;
+ }
+
+ ///
+ /// The affix slot named that or a
+ /// category above it offers; null when none does.
+ ///
+ public static IMoInflAffixSlot SlotNamed(IPartOfSpeech category, string name)
+ {
+ for (var pos = category; pos != null; pos = pos.Owner as IPartOfSpeech)
+ {
+ // BestAnalysisVernacularAlternative always returns something.
+ var match = pos.AllAffixSlots.FirstOrDefault(s => s.Name.BestAnalysisVernacularAlternative.Text == name);
+ if (match != null)
+ return match;
+ }
+ return null;
+ }
+
+ private static void MoveSlot(IMoInflAffixSlot slot, IPartOfSpeech destination)
+ {
+ destination.AffixSlotsOC.Add(slot);
+ foreach (var msa in slot.Affixes.ToList())
+ msa.PartOfSpeechRA = destination;
+ }
+
+ private static void MoveTemplate(IMoInflAffixTemplate template, IPartOfSpeech from, IPartOfSpeech destination)
+ {
+ destination.AffixTemplatesOS.Add(template);
+ if (from.Owner != destination)
+ return;
+ foreach (var slot in template.PrefixSlotsRS.Concat(template.SuffixSlotsRS).ToList())
+ {
+ if (slot.Owner == from
+ && SlotNamed(destination, slot.Name.BestAnalysisVernacularAlternative.Text) == null)
+ {
+ MoveSlot(slot, destination);
+ }
+ }
+ }
+ }
+}
diff --git a/Src/FdoUi/DetailRules/DetailObjectDuplication.cs b/Src/FdoUi/DetailRules/DetailObjectDuplication.cs
new file mode 100644
index 0000000000..4619314873
--- /dev/null
+++ b/Src/FdoUi/DetailRules/DetailObjectDuplication.cs
@@ -0,0 +1,76 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using SIL.LCModel;
+using SIL.LCModel.Core.Cellar;
+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 Duplicate command of a detail row (message DataTreeCopy): whether an object can be
+ /// duplicated, the copy of its properties onto another object, and the whole duplicate.
+ ///
+ public static class DetailObjectDuplication
+ {
+ /// Whether can copy its properties onto another
+ /// object of its class.
+ public static bool CanCopy(ICmObject original)
+ => original != null && original.IsValidObject && original is ICloneableCmObject;
+
+ ///
+ /// Whether can be duplicated: it can copy its properties
+ /// and sits in an owning sequence or collection, which takes the copy.
+ ///
+ public static bool CanDuplicate(ICmObject original)
+ {
+ if (!CanCopy(original) || original.Owner == null)
+ return false;
+ switch ((CellarPropertyType)original.Cache.MetaDataCacheAccessor.GetFieldType(original.OwningFlid))
+ {
+ case CellarPropertyType.OwningSequence:
+ case CellarPropertyType.OwningCollection:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// Copies the properties of onto
+ /// in one undoable step.
+ ///
+ public static void CopyProperties(ICmObject original, ICmObject copy, string undoText, string redoText)
+ {
+ UndoableUnitOfWorkHelper.Do(undoText, redoText, original.Cache.ActionHandlerAccessor,
+ () => ((ICloneableCmObject)original).SetCloneProperties(copy));
+ }
+
+ ///
+ /// Duplicates in one undoable step: a new object of its
+ /// class beside it, right after it in a sequence, takes a copy of its properties. The
+ /// copy is created as the model creates it, with no step a class adds to its Insert.
+ ///
+ /// The copy; null when refuses.
+ public static ICmObject Duplicate(ICmObject original, string undoText, string redoText)
+ {
+ if (!CanDuplicate(original))
+ return null;
+ var cache = original.Cache;
+ var owner = original.Owner;
+ var flid = original.OwningFlid;
+ var position = DetailObjectInsertion.InsertionPosition(owner, flid, new[] { original.Hvo });
+ ICmObject copy = null;
+ UndoableUnitOfWorkHelper.Do(undoText, redoText, cache.ActionHandlerAccessor, () =>
+ {
+ var hvo = cache.DomainDataByFlid.MakeNewObject(original.ClassID, owner.Hvo, flid, position);
+ copy = cache.ServiceLocator.ObjectRepository.GetObject(hvo);
+ ((ICloneableCmObject)original).SetCloneProperties(copy);
+ });
+ return copy;
+ }
+ }
+}
diff --git a/Src/FdoUi/DetailRules/DetailObjectInsertion.cs b/Src/FdoUi/DetailRules/DetailObjectInsertion.cs
index 068ec3db3f..e5f5391f32 100644
--- a/Src/FdoUi/DetailRules/DetailObjectInsertion.cs
+++ b/Src/FdoUi/DetailRules/DetailObjectInsertion.cs
@@ -29,6 +29,9 @@ public static class DetailObjectInsertion
/// The entry's virtual field of its variants' entry refs.
public const string VariantBackRefField = "VariantFormEntryBackRefs";
+ /// The field that holds the sub-items of a list item.
+ public const string SubItemsField = "SubPossibilities";
+
///
/// Whether a new object can go into of
/// : a vector, a virtual reference vector, or an empty atomic
@@ -103,6 +106,19 @@ public static bool CanInsert(ICmObject root, ICmObject rowObject, string command
return CanInsert(root, rowObject, fieldName, out index);
}
+ ///
+ /// Whether the shown record takes what a command inserts: a command that inserts a
+ /// sub-item applies only to a shown record of exactly its class, so each list offers
+ /// its own kind of sub-item. Any other command applies.
+ ///
+ /// The shown record.
+ public static bool OffersSubItem(ICmObject root, string fieldName, string className)
+ {
+ if (fieldName != SubItemsField)
+ return true;
+ return root != null && root.IsValidObject && root.ClassName == className;
+ }
+
///
/// Whether some class owns objects in a field named , so
/// an Insert into it creates an object rather than choosing one.
@@ -314,6 +330,21 @@ public static DetailInsertTarget ResolveTarget(ICmObject rowObject, string field
return null;
}
+ ///
+ /// The target of a row's Insert command in the view of : as
+ /// , except that
+ /// none resolves when refuses the command, and a row of
+ /// the shown record itself never inserts at its owner, which the view does not show.
+ ///
+ /// The shown record.
+ public static DetailInsertTarget ResolveTarget(ICmObject root, ICmObject rowObject, string fieldName,
+ string className, string ownerClassName, bool insertAtOwner)
+ {
+ if (!OffersSubItem(root, fieldName, className))
+ return null;
+ return ResolveTarget(rowObject, fieldName, className, ownerClassName, insertAtOwner && rowObject != root);
+ }
+
// The object and its owners, nearest first.
private static IEnumerable OwnerChainHvos(ICmObject obj)
{
diff --git a/Src/FdoUi/FdoUiTests/DetailRules/CategoryItemMoveTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/CategoryItemMoveTests.cs
new file mode 100644
index 0000000000..fee4bf1c32
--- /dev/null
+++ b/Src/FdoUi/FdoUiTests/DetailRules/CategoryItemMoveTests.cs
@@ -0,0 +1,149 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using NUnit.Framework;
+using SIL.FieldWorks.Common.DetailRules;
+using SIL.LCModel;
+using SIL.LCModel.Infrastructure;
+
+namespace SIL.FieldWorks.FdoUi
+{
+ ///
+ /// The Move rule of an affix slot and an affix template: which category holds one, what
+ /// follows it to another category, and that each move is one undoable step.
+ ///
+ [TestFixture]
+ public class CategoryItemMoveTests : MemoryOnlyBackendProviderTestBase
+ {
+ private const string Undo = "Undo Move";
+ private const string Redo = "Redo Move";
+
+ private IPartOfSpeech _verb;
+ private IPartOfSpeech _irregular;
+ private IPartOfSpeech _noun;
+ private IMoInflAffixSlot _slot;
+ private IMoInflAffixTemplate _template;
+ private ILexEntry _entry;
+ private IMoInflAffMsa _affix;
+
+ // Verb with the subcategory Irregular, and Noun. Irregular owns the slot Tense, a
+ // template that uses it as a prefix slot, and is the category of one affix in the slot.
+ [SetUp]
+ public void MakeCategories()
+ {
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ var services = Cache.ServiceLocator;
+ var categories = services.GetInstance();
+ var list = Cache.LangProject.PartsOfSpeechOA;
+ list.PossibilitiesOS.Add(_verb = categories.Create());
+ _verb.SubPossibilitiesOS.Add(_irregular = categories.Create());
+ list.PossibilitiesOS.Add(_noun = categories.Create());
+ _irregular.AffixSlotsOC.Add(_slot = services.GetInstance().Create());
+ _slot.Name.SetAnalysisDefaultWritingSystem("Tense");
+ _irregular.AffixTemplatesOS.Add(_template = services.GetInstance().Create());
+ _template.PrefixSlotsRS.Add(_slot);
+ _entry = services.GetInstance().Create();
+ _entry.MorphoSyntaxAnalysesOC.Add(_affix = services.GetInstance().Create());
+ _affix.PartOfSpeechRA = _irregular;
+ _affix.SlotsRC.Add(_slot);
+ });
+ }
+
+ [TearDown]
+ public void DeleteCategories()
+ {
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ foreach (var obj in new ICmObject[] { _entry, _verb, _noun })
+ {
+ if (obj.IsValidObject)
+ obj.Delete();
+ }
+ });
+ }
+
+ [Test]
+ public void CategoryOf_IsTheOwnerOfASlotOrATemplate_AndNullForAnythingElse()
+ {
+ Assert.That(CategoryItemMove.CategoryOf(_slot), Is.SameAs(_irregular));
+ Assert.That(CategoryItemMove.CategoryOf(_template), Is.SameAs(_irregular));
+ Assert.That(CategoryItemMove.CategoryOf(_irregular), Is.Null, "a category is no slot or template");
+ Assert.That(CategoryItemMove.CategoryOf(null), Is.Null);
+ }
+
+ [Test]
+ public void Move_ASlot_PutsItInTheCategory_WithItsAffixes_InOneUndoableStep()
+ {
+ Assert.That(CategoryItemMove.Move(_slot, _noun, Undo, Redo), Is.True);
+
+ Assert.That(_slot.Owner, Is.SameAs(_noun));
+ Assert.That(_affix.PartOfSpeechRA, Is.SameAs(_noun), "the slot's affix takes the new category");
+ Cache.ActionHandlerAccessor.Undo();
+ Assert.That(_slot.Owner, Is.SameAs(_irregular), "one Undo");
+ Assert.That(_affix.PartOfSpeechRA, Is.SameAs(_irregular));
+ }
+
+ [Test]
+ public void Move_ToTheCategoryItIsIn_OrOfAnotherKindOfObject_DoesNothing()
+ {
+ var steps = Cache.ActionHandlerAccessor.UndoableSequenceCount;
+
+ Assert.That(CategoryItemMove.Move(_slot, _irregular, Undo, Redo), Is.False);
+ Assert.That(CategoryItemMove.Move(_template, _irregular, Undo, Redo), Is.False);
+ Assert.That(CategoryItemMove.Move(_irregular, _noun, Undo, Redo), Is.False, "a category is no slot or template");
+ Assert.That(CategoryItemMove.Move(_slot, null, Undo, Redo), Is.False);
+
+ Assert.That(Cache.ActionHandlerAccessor.UndoableSequenceCount, Is.EqualTo(steps), "no undoable step");
+ }
+
+ [Test]
+ public void Move_ATemplateToTheCategoryAbove_TakesItsSlotAlong_InOneUndoableStep()
+ {
+ Assert.That(CategoryItemMove.Move(_template, _verb, Undo, Redo), Is.True);
+
+ Assert.That(_template.Owner, Is.SameAs(_verb));
+ Assert.That(_slot.Owner, Is.SameAs(_verb), "the slot it uses follows");
+ Assert.That(_affix.PartOfSpeechRA, Is.SameAs(_verb));
+ Cache.ActionHandlerAccessor.Undo();
+ Assert.That(_template.Owner, Is.SameAs(_irregular), "one Undo");
+ Assert.That(_slot.Owner, Is.SameAs(_irregular));
+ }
+
+ [Test]
+ public void Move_ATemplateToTheCategoryAbove_LeavesASlotWhoseNameIsUsedThere()
+ {
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ var taken = Cache.ServiceLocator.GetInstance().Create();
+ _verb.AffixSlotsOC.Add(taken);
+ taken.Name.SetAnalysisDefaultWritingSystem("Tense");
+ });
+
+ Assert.That(CategoryItemMove.Move(_template, _verb, Undo, Redo), Is.True);
+
+ Assert.That(_template.Owner, Is.SameAs(_verb));
+ Assert.That(_slot.Owner, Is.SameAs(_irregular), "Verb already has a slot named Tense");
+ }
+
+ [Test]
+ public void Move_ATemplateToAnotherCategoryThanTheOneAbove_LeavesItsSlots()
+ {
+ Assert.That(CategoryItemMove.Move(_template, _noun, Undo, Redo), Is.True);
+
+ Assert.That(_template.Owner, Is.SameAs(_noun));
+ Assert.That(_slot.Owner, Is.SameAs(_irregular));
+ }
+
+ [Test]
+ public void SlotNamed_FindsASlotOfTheCategoryOrOneAboveIt()
+ {
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => _verb.AffixSlotsOC.Add(_slot));
+
+ Assert.That(CategoryItemMove.SlotNamed(_irregular, "Tense"), Is.SameAs(_slot), "inherited from Verb");
+ Assert.That(CategoryItemMove.SlotNamed(_verb, "Tense"), Is.SameAs(_slot));
+ Assert.That(CategoryItemMove.SlotNamed(_noun, "Tense"), Is.Null);
+ }
+ }
+}
diff --git a/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectDuplicationTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectDuplicationTests.cs
new file mode 100644
index 0000000000..eb02d3d9ee
--- /dev/null
+++ b/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectDuplicationTests.cs
@@ -0,0 +1,112 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System.Linq;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.DetailRules;
+using SIL.LCModel;
+using SIL.LCModel.Infrastructure;
+
+namespace SIL.FieldWorks.FdoUi
+{
+ ///
+ /// The Duplicate rule of a detail row: which objects it applies to, where the copy goes,
+ /// and that the whole duplicate is one undoable step.
+ ///
+ [TestFixture]
+ public class DetailObjectDuplicationTests : MemoryOnlyBackendProviderTestBase
+ {
+ private const string Undo = "Undo Duplicate";
+ private const string Redo = "Redo Duplicate";
+
+ private IPartOfSpeech _category;
+ private IMoInflAffixSlot _slot;
+ private IMoInflAffixTemplate _first;
+ private IMoInflAffixTemplate _second;
+ private ILexEntry _entry;
+
+ // A category with the slot Tense and two templates; the first uses the slot.
+ [SetUp]
+ public void MakeCategory()
+ {
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ var services = Cache.ServiceLocator;
+ Cache.LangProject.PartsOfSpeechOA.PossibilitiesOS.Add(
+ _category = services.GetInstance().Create());
+ _category.AffixSlotsOC.Add(_slot = services.GetInstance().Create());
+ _slot.Name.SetAnalysisDefaultWritingSystem("Tense");
+ var templates = services.GetInstance();
+ _category.AffixTemplatesOS.Add(_first = templates.Create());
+ _first.Name.SetAnalysisDefaultWritingSystem("Finite");
+ _first.PrefixSlotsRS.Add(_slot);
+ _category.AffixTemplatesOS.Add(_second = templates.Create());
+ _entry = services.GetInstance().Create();
+ });
+ }
+
+ [TearDown]
+ public void DeleteCategory()
+ {
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ foreach (var obj in new ICmObject[] { _entry, _category })
+ {
+ if (obj.IsValidObject)
+ obj.Delete();
+ }
+ });
+ }
+
+ [Test]
+ public void CanDuplicate_AnObjectThatCopiesItself_InAnOwningVector()
+ {
+ Assert.That(DetailObjectDuplication.CanDuplicate(_slot), Is.True, "in an owning collection");
+ Assert.That(DetailObjectDuplication.CanDuplicate(_first), Is.True, "in an owning sequence");
+ Assert.That(DetailObjectDuplication.CanDuplicate(_entry), Is.False, "an entry does not copy itself");
+ Assert.That(DetailObjectDuplication.CanDuplicate(null), Is.False);
+ Assert.That(DetailObjectDuplication.Duplicate(_entry, Undo, Redo), Is.Null);
+ }
+
+ [Test]
+ public void Duplicate_ATemplate_PutsTheCopyRightAfterIt_InOneUndoableStep()
+ {
+ var copy = (IMoInflAffixTemplate)DetailObjectDuplication.Duplicate(_first, Undo, Redo);
+
+ Assert.That(_category.AffixTemplatesOS.ToList(), Is.EqualTo(new[] { _first, copy, _second }));
+ Assert.That(copy.PrefixSlotsRS.ToList(), Is.EqualTo(new[] { _slot }), "the copy uses the same slot");
+ Assert.That(copy.Name.AnalysisDefaultWritingSystem.Text, Is.EqualTo("Finite2"), "the copy's name is numbered");
+ Cache.ActionHandlerAccessor.Undo();
+ Assert.That(_category.AffixTemplatesOS.ToList(), Is.EqualTo(new[] { _first, _second }), "one Undo");
+ }
+
+ [Test]
+ public void Duplicate_ASlot_AddsTheCopyToItsCategory_InOneUndoableStep()
+ {
+ var copy = (IMoInflAffixSlot)DetailObjectDuplication.Duplicate(_slot, Undo, Redo);
+
+ Assert.That(_category.AffixSlotsOC, Is.EquivalentTo(new[] { _slot, copy }));
+ Assert.That(_slot.Name.AnalysisDefaultWritingSystem.Text, Is.EqualTo("Tense"), "the original keeps its name");
+ Assert.That(copy.Name.AnalysisDefaultWritingSystem.Text, Is.EqualTo("Tense2"), "the copy's name is numbered");
+ Cache.ActionHandlerAccessor.Undo();
+ Assert.That(_category.AffixSlotsOC, Is.EquivalentTo(new[] { _slot }), "one Undo");
+ }
+
+ [Test]
+ public void CopyProperties_CopiesOntoAnExistingObject_InItsOwnUndoableStep()
+ {
+ IMoInflAffixSlot other = null;
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor,
+ () => _category.AffixSlotsOC.Add(other = Cache.ServiceLocator.GetInstance().Create()));
+ Assert.That(DetailObjectDuplication.CanCopy(_slot), Is.True);
+ Assert.That(DetailObjectDuplication.CanCopy(_entry), Is.False);
+
+ DetailObjectDuplication.CopyProperties(_slot, other, Undo, Redo);
+
+ Assert.That(other.Name.AnalysisDefaultWritingSystem.Text, Is.EqualTo("Tense2"));
+ Cache.ActionHandlerAccessor.Undo();
+ Assert.That(other.Name.AnalysisDefaultWritingSystem.Text, Is.Null.Or.Empty, "one Undo");
+ }
+ }
+}
diff --git a/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectInsertionTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectInsertionTests.cs
index 7cf6d98358..6d0e74d3d7 100644
--- a/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectInsertionTests.cs
+++ b/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectInsertionTests.cs
@@ -2,6 +2,8 @@
// This software is licensed under the LGPL, version 2.1 or later
// (http://www.gnu.org/licenses/lgpl-2.1.html)
+using System.Collections.Generic;
+using System.Linq;
using NUnit.Framework;
using SIL.FieldWorks.Common.DetailRules;
using SIL.LCModel;
@@ -25,6 +27,7 @@ public class DetailObjectInsertionTests : MemoryOnlyBackendProviderTestBase
private ILexExtendedNote _note2;
private ILexEtymology _etymology1;
private ILexEtymology _etymology2;
+ private readonly List _categories = new List();
// A stem entry with two senses, two etymologies, and on the first sense one example
// and two extended notes.
@@ -62,7 +65,10 @@ public void DeleteEntry()
{
if (_entry.IsValidObject)
_entry.Delete();
+ foreach (var category in _categories.Where(c => c.IsValidObject))
+ category.Delete();
});
+ _categories.Clear();
}
[Test]
@@ -356,5 +362,78 @@ public void ResolveTarget_IsNull_ForAReferenceField_AFilledAtomicField_AnUnknown
"nothing from the etymology up to the entry has ExtendedNote");
Assert.That(DetailObjectInsertion.ResolveTarget(null, "Senses", "LexSense", null, false), Is.Null);
}
+
+ [Test]
+ public void OffersSubItem_OnlyToAShownRecordOfTheCommandsClass()
+ {
+ var category = MakeCategory(null);
+
+ Assert.That(DetailObjectInsertion.OffersSubItem(category, "SubPossibilities", "PartOfSpeech"), Is.True);
+ Assert.That(DetailObjectInsertion.OffersSubItem(category, "SubPossibilities", "CmPossibility"), Is.False,
+ "another list's kind of sub-item");
+ Assert.That(DetailObjectInsertion.OffersSubItem(null, "SubPossibilities", "PartOfSpeech"), Is.False);
+ Assert.That(DetailObjectInsertion.OffersSubItem(_entry, "Senses", "PartOfSpeech"), Is.True,
+ "a command that inserts no sub-item applies to any record");
+ }
+
+ // The Subcategory command asks for the owner; a row of the shown category itself
+ // inserts under that category all the same, top-level or nested.
+ [Test]
+ public void ResolveTarget_ASubcategoryFromARowOfTheShownCategory_GoesUnderIt()
+ {
+ var top = MakeCategory(null);
+ var nested = MakeCategory(top);
+
+ foreach (var shown in new[] { top, nested })
+ {
+ var target = DetailObjectInsertion.ResolveTarget(shown, shown, "SubPossibilities", "PartOfSpeech", null,
+ insertAtOwner: true);
+
+ Assert.That(target.Owner, Is.SameAs(shown));
+ Assert.That(target.Flid, Is.EqualTo(CmPossibilityTags.kflidSubPossibilities));
+ Assert.That(target.ClassId, Is.EqualTo(PartOfSpeechTags.kClassId));
+ }
+ }
+
+ [Test]
+ public void ResolveTarget_ASubcategoryFromARowOfASubcategory_GoesRightAfterIt()
+ {
+ var top = MakeCategory(null);
+ var first = MakeCategory(top);
+ MakeCategory(top);
+
+ var target = DetailObjectInsertion.ResolveTarget(top, first, "SubPossibilities", "PartOfSpeech", null,
+ insertAtOwner: true);
+
+ Assert.That(target.Owner, Is.SameAs(top));
+ Assert.That(target.Position, Is.EqualTo(1), "after the first subcategory, before the second");
+ }
+
+ [Test]
+ public void ResolveTarget_ASubItemOfAnotherClassThanTheShownRecord_ResolvesNone()
+ {
+ var category = MakeCategory(null);
+
+ Assert.That(DetailObjectInsertion.ResolveTarget(category, category, "SubPossibilities", "CmPossibility", null, false),
+ Is.Null);
+ Assert.That(DetailObjectInsertion.ResolveTarget(_entry, _sense1, "Senses", "LexSense", "LexSense", false).Owner,
+ Is.SameAs(_sense1), "any other command resolves as without a shown record");
+ }
+
+ // A category at the top of the list, or under another; deleted with the test.
+ private IPartOfSpeech MakeCategory(IPartOfSpeech owner)
+ {
+ IPartOfSpeech category = null;
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ category = Cache.ServiceLocator.GetInstance().Create();
+ if (owner == null)
+ Cache.LangProject.PartsOfSpeechOA.PossibilitiesOS.Add(category);
+ else
+ owner.SubPossibilitiesOS.Add(category);
+ });
+ _categories.Add(category);
+ return category;
+ }
}
}
diff --git a/Src/LexText/LexTextControls/Avalonia/LcmCategoryChooserLauncher.cs b/Src/LexText/LexTextControls/Avalonia/LcmCategoryChooserLauncher.cs
new file mode 100644
index 0000000000..e3de342265
--- /dev/null
+++ b/Src/LexText/LexTextControls/Avalonia/LcmCategoryChooserLauncher.cs
@@ -0,0 +1,91 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Linq;
+using System.Windows.Forms;
+using FwAvaloniaDialogs;
+using SIL.FieldWorks.Common.FwAvalonia.Detail;
+using SIL.LCModel;
+using AvControl = Avalonia.Controls.Control;
+
+namespace SIL.FieldWorks.LexText.Controls
+{
+ ///
+ /// Asks the user for one of the project's categories with the chooser dialog, shown as
+ /// the category tree with nothing selected.
+ ///
+ public sealed class LcmCategoryChooserLauncher
+ : AvaloniaDialogLauncher
+ {
+ private readonly LcmCache _cache;
+ private readonly string _title;
+ private ChooserDialogViewModel _viewModel;
+
+ private LcmCategoryChooserLauncher(LcmCache cache, string title)
+ {
+ _cache = cache;
+ _title = title;
+ }
+
+ ///
+ /// Shows the project's categories over .
+ ///
+ /// The dialog's title.
+ /// The chosen category; null when the user cancels.
+ public static IPartOfSpeech Choose(LcmCache cache, IWin32Window owner, string title)
+ {
+ if (cache == null)
+ throw new ArgumentNullException(nameof(cache));
+ var outcome = new LcmCategoryChooserLauncher(cache, title).Run(owner);
+ return outcome.Accepted ? outcome.Payload : null;
+ }
+
+ protected override string DialogTitle => _title;
+ protected override bool Resizable => true;
+ protected override int DialogWidth => 420;
+ protected override int DialogHeight => 460;
+
+ protected override ChooserDialogInput BuildState() => BuildInput(_cache);
+
+ ///
+ /// The chooser's input: every category as a tree, with one choice required before OK.
+ ///
+ internal static ChooserDialogInput BuildInput(LcmCache cache)
+ {
+ return new ChooserDialogInput
+ {
+ Candidates = LcmInsertEntryDialogLauncher.BuildPosNodes(cache)
+ .Select(n => new DetailChoiceOption(n.Id, n.Name, n.Depth)).ToList(),
+ SelectionMode = ChooserSelectionMode.Single,
+ InitialSelectedKeys = Array.Empty(),
+ AllowEmpty = false,
+ Hierarchical = true,
+ ForbidEmptySelection = true
+ };
+ }
+
+ /// The category a chosen key names; null for no key or one that names
+ /// none.
+ internal static IPartOfSpeech Resolve(LcmCache cache, string key)
+ {
+ return Guid.TryParse(key, out var guid)
+ && cache.ServiceLocator.GetInstance().TryGetObject(guid, out var category)
+ ? category
+ : null;
+ }
+
+ protected override ChooserDialogViewModel CreateViewModel(ChooserDialogInput state)
+ {
+ _viewModel = new ChooserDialogViewModel(state);
+ return _viewModel;
+ }
+
+ protected override AvControl CreateView(ChooserDialogViewModel viewModel)
+ => new ChooserDialogView { DataContext = viewModel };
+
+ protected override IPartOfSpeech Apply(ChooserDialogInput state)
+ => Resolve(_cache, _viewModel?.ChosenKeys?.FirstOrDefault());
+ }
+}
diff --git a/Src/LexText/LexTextControls/LexTextControlsTests/Avalonia/LcmCategoryChooserLauncherTests.cs b/Src/LexText/LexTextControls/LexTextControlsTests/Avalonia/LcmCategoryChooserLauncherTests.cs
new file mode 100644
index 0000000000..69d1d43c69
--- /dev/null
+++ b/Src/LexText/LexTextControls/LexTextControlsTests/Avalonia/LcmCategoryChooserLauncherTests.cs
@@ -0,0 +1,68 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System.Linq;
+using FwAvaloniaDialogs;
+using NUnit.Framework;
+using SIL.FieldWorks.LexText.Controls;
+using SIL.LCModel;
+
+namespace LexTextControlsTests
+{
+ ///
+ /// The category chooser's input and result: the project's categories as a tree with a
+ /// choice required, and the category a chosen key names. The modal loop itself is not
+ /// run here.
+ ///
+ [TestFixture]
+ public class LcmCategoryChooserLauncherTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase
+ {
+ private IPartOfSpeech m_verb;
+ private IPartOfSpeech m_irregular;
+ private IPartOfSpeech m_noun;
+
+ // Verb with the subcategory Irregular, then Noun; the base's open unit of work takes
+ // the edits.
+ private void MakeCategories()
+ {
+ var categories = Cache.ServiceLocator.GetInstance();
+ var list = Cache.LangProject.PartsOfSpeechOA;
+ list.PossibilitiesOS.Add(m_verb = categories.Create());
+ m_verb.Name.SetAnalysisDefaultWritingSystem("Verb");
+ m_verb.SubPossibilitiesOS.Add(m_irregular = categories.Create());
+ m_irregular.Name.SetAnalysisDefaultWritingSystem("Irregular");
+ list.PossibilitiesOS.Add(m_noun = categories.Create());
+ m_noun.Name.SetAnalysisDefaultWritingSystem("Noun");
+ }
+
+ [Test]
+ public void BuildInput_OffersEveryCategoryAsATree_WithNothingSelected_AndRequiresAChoice()
+ {
+ MakeCategories();
+
+ var input = LcmCategoryChooserLauncher.BuildInput(Cache);
+
+ var mine = input.Candidates.Where(c => new[] { "Verb", "Irregular", "Noun" }.Contains(c.Name)).ToList();
+ Assert.That(mine.Select(c => c.Name), Is.EqualTo(new[] { "Verb", "Irregular", "Noun" }), "list order");
+ Assert.That(mine.Select(c => c.Depth), Is.EqualTo(new[] { 0, 1, 0 }));
+ Assert.That(input.Hierarchical, Is.True);
+ Assert.That(input.SelectionMode, Is.EqualTo(ChooserSelectionMode.Single));
+ Assert.That(input.AllowEmpty, Is.False);
+ Assert.That(input.ForbidEmptySelection, Is.True);
+ Assert.That(input.InitialSelectedKeys, Is.Empty, "OK waits for a choice");
+ }
+
+ [Test]
+ public void Resolve_ReturnsTheCategoryAKeyNames_AndNullForNoneOrAnother()
+ {
+ MakeCategories();
+ var key = LcmCategoryChooserLauncher.BuildInput(Cache).Candidates.Single(c => c.Name == "Noun").Key;
+
+ Assert.That(LcmCategoryChooserLauncher.Resolve(Cache, key), Is.SameAs(m_noun));
+ Assert.That(LcmCategoryChooserLauncher.Resolve(Cache, null), Is.Null);
+ Assert.That(LcmCategoryChooserLauncher.Resolve(Cache, "not a guid"), Is.Null);
+ Assert.That(LcmCategoryChooserLauncher.Resolve(Cache, System.Guid.NewGuid().ToString()), Is.Null);
+ }
+ }
+}
diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.CategoryMenus.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.CategoryMenus.cs
new file mode 100644
index 0000000000..597e13bf8b
--- /dev/null
+++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.CategoryMenus.cs
@@ -0,0 +1,125 @@
+// 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.Windows.Forms;
+using SIL.FieldWorks.Common.DetailRules;
+using SIL.FieldWorks.Common.FwAvalonia.Detail;
+using SIL.FieldWorks.LexText.Controls;
+using SIL.LCModel;
+using SIL.Reporting;
+using XCore;
+
+namespace SIL.FieldWorks.XWorks
+{
+ ///
+ /// The host half of Category Edit's item menus: Duplicate and Move on the rows of an affix
+ /// slot or an affix template.
+ ///
+ public partial class RecordEditView
+ {
+ /// The tool whose Insert menu commands this view answers for a
+ /// category.
+ internal const string CategoryEditTool = "posEdit";
+
+ /// The message every Duplicate leaf of the detail menus sends.
+ internal const string DuplicateMessage = "DataTreeCopy";
+
+ /// The message of Move Affix Slot and Move Affix Template.
+ internal const string MoveToCategoryMessage = "DataTreeMove";
+
+ ///
+ /// Picks the category to move a slot or template to, given the category it is in and
+ /// the command's label, or null to cancel: the product asks with
+ /// ; a headless test answers for the user.
+ ///
+ internal Func MoveDestinationChosen { get; set; }
+
+ ///
+ /// The Duplicate item for the row's object: absent on a row with no object, a ghost
+ /// row and a row whose object the command does not act on; disabled when
+ /// refuses the object.
+ ///
+ internal DetailMenuItem DuplicateItem(DetailMenuLeaf leaf, DetailField field)
+ {
+ var target = RowObjectOf(field);
+ if (target == null || field.GhostPrompt != null || !CommandActsOn(target, leaf.Command))
+ return null;
+ var label = XCoreMenuBridge.StripAccelerator(leaf.Label);
+ if (!DetailObjectDuplication.CanDuplicate(target))
+ return DetailMenuItem.Disabled(label);
+ return new DetailMenuItem(label, isEnabled: true, isChecked: false, children: null,
+ execute: () => DuplicateDetailObject(field, leaf.Command));
+ }
+
+ ///
+ /// Duplicates the row's object beside it: pending edits settle first, the copy is one
+ /// undoable step, and the re-show focuses the copy's first row.
+ ///
+ /// The copy, or null when nothing was duplicated.
+ internal ICmObject DuplicateDetailObject(DetailField field, Command command)
+ {
+ SettleDetailEdits();
+ var copy = DetailObjectDuplication.Duplicate(RowObjectOf(field), command.UndoText, command.RedoText);
+ if (copy == null)
+ {
+ Logger.WriteEvent("Duplicate did nothing: the row's object " + field?.ObjectHvo + " cannot be duplicated.");
+ return null;
+ }
+ m_avaloniaEntryForm?.FocusObjectOnNextShow(copy.Hvo);
+ OnAvaloniaDetailEditCompleted(this, EventArgs.Empty);
+ return copy;
+ }
+
+ ///
+ /// The Move item for the row's affix slot or affix template: absent on a row with no
+ /// object, a ghost row, and a row whose object the command does not act on or no
+ /// category owns; enabled otherwise.
+ ///
+ internal DetailMenuItem MoveToCategoryItem(DetailMenuLeaf leaf, DetailField field)
+ {
+ var target = RowObjectOf(field);
+ if (target == null || field.GhostPrompt != null || !CommandActsOn(target, leaf.Command)
+ || CategoryItemMove.CategoryOf(target) == null)
+ {
+ return null;
+ }
+ var label = XCoreMenuBridge.StripAccelerator(leaf.Label);
+ return new DetailMenuItem(label, isEnabled: true, isChecked: false, children: null,
+ execute: () => MoveToCategory(field, leaf.Command, label));
+ }
+
+ ///
+ /// Moves the row's slot or template to the category the user picks through
+ /// : pending edits settle first, the move is one
+ /// undoable step, and the re-show focuses the nearest row that is left.
+ ///
+ /// The title of the dialog that asks for the category.
+ /// True when the object moved.
+ internal bool MoveToCategory(DetailField field, Command command, string title)
+ {
+ SettleDetailEdits();
+ var target = RowObjectOf(field);
+ var current = CategoryItemMove.CategoryOf(target);
+ if (current == null)
+ {
+ Logger.WriteEvent("Move did nothing: no category owns the row's object " + field?.ObjectHvo + ".");
+ return false;
+ }
+ var destination = (MoveDestinationChosen ?? ChooseCategoryWithDialog)(current, title);
+ if (destination == null)
+ return false;
+ var focusRows = RowsToFocusAfterDeleting(m_avaloniaEntryForm?.ShownDetail, target);
+ if (!CategoryItemMove.Move(target, destination, command.UndoText, command.RedoText))
+ return false;
+ if (focusRows.Count > 0)
+ m_avaloniaEntryForm?.FocusRowOnNextShow(focusRows);
+ OnAvaloniaDetailEditCompleted(this, EventArgs.Empty);
+ return true;
+ }
+
+ private IPartOfSpeech ChooseCategoryWithDialog(IPartOfSpeech current, string title)
+ => LcmCategoryChooserLauncher.Choose(Cache, m_propertyTable.GetValue