Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions Src/Common/Controls/DetailControls/Slice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
91 changes: 91 additions & 0 deletions Src/FdoUi/DetailRules/CategoryItemMove.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// The Move command of an affix slot or an affix template (message DataTreeMove): the
/// category that holds it, and its move to another category.
/// </summary>
public static class CategoryItemMove
{
/// <summary>
/// The category that owns <paramref name="target"/> when it is an affix slot or an
/// affix template; null for anything else.
/// </summary>
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;
}

/// <summary>
/// Moves the slot or template to <paramref name="destination"/> 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.
/// </summary>
/// <returns>True when it moved; false for the category it is already in, and for an
/// object that is no slot or template.</returns>
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;
}

/// <summary>
/// The affix slot named <paramref name="name"/> that <paramref name="category"/> or a
/// category above it offers; null when none does.
/// </summary>
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);
}
}
}
}
}
76 changes: 76 additions & 0 deletions Src/FdoUi/DetailRules/DetailObjectDuplication.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public static class DetailObjectDuplication
{
/// <summary>Whether <paramref name="original"/> can copy its properties onto another
/// object of its class.</summary>
public static bool CanCopy(ICmObject original)
=> original != null && original.IsValidObject && original is ICloneableCmObject;

/// <summary>
/// Whether <paramref name="original"/> can be duplicated: it can copy its properties
/// and sits in an owning sequence or collection, which takes the copy.
/// </summary>
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;
}
}

/// <summary>
/// Copies the properties of <paramref name="original"/> onto <paramref name="copy"/>
/// in one undoable step.
/// </summary>
public static void CopyProperties(ICmObject original, ICmObject copy, string undoText, string redoText)
{
UndoableUnitOfWorkHelper.Do(undoText, redoText, original.Cache.ActionHandlerAccessor,
() => ((ICloneableCmObject)original).SetCloneProperties(copy));
}

/// <summary>
/// Duplicates <paramref name="original"/> 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.
/// </summary>
/// <returns>The copy; null when <see cref="CanDuplicate"/> refuses.</returns>
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;
}
}
}
31 changes: 31 additions & 0 deletions Src/FdoUi/DetailRules/DetailObjectInsertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ public static class DetailObjectInsertion
/// <summary>The entry's virtual field of its variants' entry refs.</summary>
public const string VariantBackRefField = "VariantFormEntryBackRefs";

/// <summary>The field that holds the sub-items of a list item.</summary>
public const string SubItemsField = "SubPossibilities";

/// <summary>
/// Whether a new object can go into <paramref name="fieldName"/> of
/// <paramref name="owner"/>: a vector, a virtual reference vector, or an empty atomic
Expand Down Expand Up @@ -103,6 +106,19 @@ public static bool CanInsert(ICmObject root, ICmObject rowObject, string command
return CanInsert(root, rowObject, fieldName, out index);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="root">The shown record.</param>
public static bool OffersSubItem(ICmObject root, string fieldName, string className)
{
if (fieldName != SubItemsField)
return true;
return root != null && root.IsValidObject && root.ClassName == className;
}

/// <summary>
/// Whether some class owns objects in a field named <paramref name="fieldName"/>, so
/// an Insert into it creates an object rather than choosing one.
Expand Down Expand Up @@ -314,6 +330,21 @@ public static DetailInsertTarget ResolveTarget(ICmObject rowObject, string field
return null;
}

/// <summary>
/// The target of a row's Insert command in the view of <paramref name="root"/>: as
/// <see cref="ResolveTarget(ICmObject, string, string, string, bool)"/>, except that
/// none resolves when <see cref="OffersSubItem"/> refuses the command, and a row of
/// the shown record itself never inserts at its owner, which the view does not show.
/// </summary>
/// <param name="root">The shown record.</param>
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<int> OwnerChainHvos(ICmObject obj)
{
Expand Down
149 changes: 149 additions & 0 deletions Src/FdoUi/FdoUiTests/DetailRules/CategoryItemMoveTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
[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<IPartOfSpeechFactory>();
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<IMoInflAffixSlotFactory>().Create());
_slot.Name.SetAnalysisDefaultWritingSystem("Tense");
_irregular.AffixTemplatesOS.Add(_template = services.GetInstance<IMoInflAffixTemplateFactory>().Create());
_template.PrefixSlotsRS.Add(_slot);
_entry = services.GetInstance<ILexEntryFactory>().Create();
_entry.MorphoSyntaxAnalysesOC.Add(_affix = services.GetInstance<IMoInflAffMsaFactory>().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<IMoInflAffixSlotFactory>().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);
}
}
}
Loading
Loading