diff --git a/Src/FdoUi/DetailRules/DetailSequenceMove.cs b/Src/FdoUi/DetailRules/DetailSequenceMove.cs
new file mode 100644
index 0000000000..1a6b73e208
--- /dev/null
+++ b/Src/FdoUi/DetailRules/DetailSequenceMove.cs
@@ -0,0 +1,98 @@
+// 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 Move Up and Move Down commands of a detail row: whether the row's object can move
+ /// one place within its owning sequence, and the move itself.
+ ///
+ public static class DetailSequenceMove
+ {
+ ///
+ /// Whether sits in an owning sequence, so the move items
+ /// apply to it at all: false for an unowned object and for one in an atomic or
+ /// collection field.
+ ///
+ public static bool IsInSequence(ICmObject target)
+ {
+ if (target == null || !target.IsValidObject || target.Owner == null || target.OwningFlid <= 0)
+ return false;
+ return (CellarPropertyType)target.Cache.MetaDataCacheAccessor.GetFieldType(target.OwningFlid)
+ == CellarPropertyType.OwningSequence;
+ }
+
+ ///
+ /// Whether can move one place up: it is in a sequence and
+ /// not first. The first slot of an entry's EntryRefs belongs to a complex form, so
+ /// while the first ref is one the second cannot move up.
+ ///
+ public static bool CanMoveUp(ICmObject target)
+ {
+ if (!IsInSequence(target))
+ return false;
+ var index = IndexOf(target);
+ if (index <= 0)
+ return false;
+ if (index == 1 && target.OwningFlid == LexEntryTags.kflidEntryRefs)
+ return ((ILexEntry)target.Owner).EntryRefsOS[0].ComplexEntryTypesRS.Count == 0;
+ return true;
+ }
+
+ /// Whether can move one place down: it is in a
+ /// sequence and not last.
+ public static bool CanMoveDown(ICmObject target)
+ {
+ if (!IsInSequence(target))
+ return false;
+ var index = IndexOf(target);
+ return index >= 0 && index + 1 < Count(target);
+ }
+
+ /// Moves one place up in one undoable step.
+ /// True when it moved.
+ public static bool MoveUp(ICmObject target, string undoText, string redoText)
+ {
+ if (!CanMoveUp(target))
+ return false;
+ var index = IndexOf(target);
+ Move(target, index, index - 1, undoText, redoText);
+ return true;
+ }
+
+ /// Moves one place down in one undoable
+ /// step.
+ /// True when it moved.
+ public static bool MoveDown(ICmObject target, string undoText, string redoText)
+ {
+ if (!CanMoveDown(target))
+ return false;
+ var index = IndexOf(target);
+ // MoveOwnSeq inserts before the destination, so one place down is two on.
+ Move(target, index, index + 2, undoText, redoText);
+ return true;
+ }
+
+ private static void Move(ICmObject target, int index, int destination, string undoText, string redoText)
+ {
+ var cache = target.Cache;
+ var owner = target.Owner.Hvo;
+ var flid = target.OwningFlid;
+ UndoableUnitOfWorkHelper.Do(undoText, redoText, cache.ActionHandlerAccessor,
+ () => cache.DomainDataByFlid.MoveOwnSeq(owner, flid, index, index, owner, flid, destination));
+ }
+
+ private static int IndexOf(ICmObject target)
+ => target.Cache.DomainDataByFlid.GetObjIndex(target.Owner.Hvo, target.OwningFlid, target.Hvo);
+
+ private static int Count(ICmObject target)
+ => target.Cache.DomainDataByFlid.get_VecSize(target.Owner.Hvo, target.OwningFlid);
+ }
+}
diff --git a/Src/FdoUi/DetailRules/MediaFileInsertion.cs b/Src/FdoUi/DetailRules/MediaFileInsertion.cs
new file mode 100644
index 0000000000..3933b097ac
--- /dev/null
+++ b/Src/FdoUi/DetailRules/MediaFileInsertion.cs
@@ -0,0 +1,103 @@
+// 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.Collections.Generic;
+using System.Linq;
+using SIL.FieldWorks.Common.FwUtils;
+using SIL.LCModel;
+using SIL.LCModel.DomainServices;
+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 Insert Sound or Movie command: which pronunciation takes the files, and the media
+ /// links it gets. Choosing the files stays with the caller.
+ ///
+ public static class MediaFileInsertion
+ {
+ /// The strings-table key of the media folder's name.
+ public const string MediaFolderKey = "kstidMediaFolder";
+
+ ///
+ /// The entry whose pronunciation takes the files: the one
+ /// is or belongs to, else when that is an entry; null when
+ /// neither is.
+ ///
+ public static ILexEntry TargetEntry(ICmObject rowObject, ICmObject root)
+ {
+ var entry = rowObject as ILexEntry ?? rowObject?.OwnerOfClass();
+ return entry ?? root as ILexEntry;
+ }
+
+ ///
+ /// The pronunciation the files go to: when it is one,
+ /// else the first pronunciation of ; null when there is no
+ /// entry or it has no pronunciation yet (see ).
+ ///
+ public static ILexPronunciation TargetPronunciation(ICmObject rowObject, ICmObject root)
+ {
+ if (rowObject is ILexPronunciation pronunciation)
+ return pronunciation;
+ return TargetEntry(rowObject, root)?.PronunciationsOS.FirstOrDefault();
+ }
+
+ ///
+ /// The entry's first pronunciation, created in its own undoable step when it has none.
+ /// The default pronunciation writing system is read first, which initializes the
+ /// pronunciation writing systems (FWR-2086).
+ ///
+ public static ILexPronunciation FirstOrCreatePronunciation(ILexEntry entry, string undoText, string redoText)
+ {
+ if (entry.PronunciationsOS.Count == 0)
+ {
+ var cache = entry.Cache;
+ _ = cache.DefaultPronunciationWs;
+ UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW(undoText, redoText, cache.ActionHandlerAccessor,
+ () => entry.PronunciationsOS.Add(cache.ServiceLocator.GetInstance().Create()));
+ }
+ return entry.PronunciationsOS[0];
+ }
+
+ ///
+ /// Links each file to as a new media object whose
+ /// file sits in the project's media folder, in one undoable step. The links follow
+ /// the existing ones in reverse name order, since the file dialog lists the last
+ /// picked file first. Empty names are skipped.
+ ///
+ /// The media objects created, in their order in the pronunciation.
+ public static IReadOnlyList Insert(ILexPronunciation pronunciation, IEnumerable fileNames,
+ string undoText, string redoText)
+ {
+ var created = new List();
+ var names = fileNames.Where(f => !string.IsNullOrEmpty(f)).ToList();
+ if (names.Count == 0 || !pronunciation.IsValidObject)
+ return created;
+ var cache = pronunciation.Cache;
+ UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW(undoText, redoText, cache.ActionHandlerAccessor, () =>
+ {
+ var folder = DomainObjectServices.FindOrCreateFolder(cache, LangProjectTags.kflidMedia, MediaFolderName());
+ var end = pronunciation.MediaFilesOS.Count;
+ foreach (var name in names)
+ {
+ var media = cache.ServiceLocator.GetInstance().Create();
+ pronunciation.MediaFilesOS.Insert(end, media);
+ media.MediaFileRA = DomainObjectServices.FindOrCreateFile(folder, name);
+ created.Insert(0, media);
+ }
+ });
+ return created;
+ }
+
+ /// The media folder's name: the strings-table entry, else the default
+ /// folder.
+ public static string MediaFolderName()
+ {
+ var name = StringTable.Table.GetString(MediaFolderKey);
+ return string.IsNullOrEmpty(name) || name == "*" + MediaFolderKey + "*" ? CmFolderTags.LocalMedia : name;
+ }
+ }
+}
diff --git a/Src/FdoUi/FdoUiTests/DetailRules/DetailSequenceMoveTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/DetailSequenceMoveTests.cs
new file mode 100644
index 0000000000..ee1b23d7b9
--- /dev/null
+++ b/Src/FdoUi/FdoUiTests/DetailRules/DetailSequenceMoveTests.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.Linq;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.DetailRules;
+using SIL.LCModel;
+using SIL.LCModel.Infrastructure;
+
+namespace SIL.FieldWorks.FdoUi
+{
+ ///
+ /// The Move Up / Move Down rule of a detail row: where it applies, the ends of the
+ /// sequence, the complex-form slot of an entry's EntryRefs, and the moves themselves.
+ ///
+ [TestFixture]
+ public class DetailSequenceMoveTests : MemoryOnlyBackendProviderTestBase
+ {
+ private ILexEntry _entry;
+ private ILexPronunciation _first;
+ private ILexPronunciation _middle;
+ private ILexPronunciation _last;
+ private ILexEntryRef _complexForm;
+ private ILexEntryRef _variant1;
+ private ILexEntryRef _variant2;
+
+ // An entry with a lexeme form, three pronunciations and three entry refs: a complex
+ // form first, then two variants.
+ [SetUp]
+ public void MakeEntry()
+ {
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ _entry = Cache.ServiceLocator.GetInstance().Create();
+ _entry.LexemeFormOA = Cache.ServiceLocator.GetInstance().Create();
+ var pronunciations = Cache.ServiceLocator.GetInstance();
+ _entry.PronunciationsOS.Add(_first = pronunciations.Create());
+ _entry.PronunciationsOS.Add(_middle = pronunciations.Create());
+ _entry.PronunciationsOS.Add(_last = pronunciations.Create());
+ var refs = Cache.ServiceLocator.GetInstance();
+ _entry.EntryRefsOS.Add(_complexForm = refs.Create());
+ _complexForm.RefType = LexEntryRefTags.krtComplexForm;
+ _complexForm.ComplexEntryTypesRS.Add(Cache.ServiceLocator.GetInstance()
+ .GetObject(LexEntryTypeTags.kguidLexTypCompound));
+ _entry.EntryRefsOS.Add(_variant1 = refs.Create());
+ _variant1.RefType = LexEntryRefTags.krtVariant;
+ _entry.EntryRefsOS.Add(_variant2 = refs.Create());
+ _variant2.RefType = LexEntryRefTags.krtVariant;
+ });
+ }
+
+ [TearDown]
+ public void DeleteEntry()
+ {
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ if (_entry.IsValidObject)
+ _entry.Delete();
+ });
+ }
+
+ [Test]
+ public void IsInSequence_OnlyForAnObjectInAnOwningSequence()
+ {
+ Assert.That(DetailSequenceMove.IsInSequence(_middle), Is.True);
+ Assert.That(DetailSequenceMove.IsInSequence(_entry), Is.False, "unowned");
+ Assert.That(DetailSequenceMove.IsInSequence(_entry.LexemeFormOA), Is.False, "an atomic field");
+ Assert.That(DetailSequenceMove.IsInSequence(null), Is.False);
+ }
+
+ [Test]
+ public void CanMoveUp_AndDown_StopAtTheEnds()
+ {
+ Assert.That(DetailSequenceMove.CanMoveUp(_first), Is.False);
+ Assert.That(DetailSequenceMove.CanMoveUp(_middle), Is.True);
+ Assert.That(DetailSequenceMove.CanMoveUp(_last), Is.True);
+ Assert.That(DetailSequenceMove.CanMoveDown(_first), Is.True);
+ Assert.That(DetailSequenceMove.CanMoveDown(_middle), Is.True);
+ Assert.That(DetailSequenceMove.CanMoveDown(_last), Is.False);
+ Assert.That(DetailSequenceMove.CanMoveUp(_entry), Is.False, "not in a sequence");
+ Assert.That(DetailSequenceMove.CanMoveDown(_entry), Is.False);
+ }
+
+ // The first EntryRefs slot is the complex form's: the second ref cannot move up while
+ // the first is a complex form, and can once it is not.
+ [Test]
+ public void CanMoveUp_InEntryRefs_KeepsTheComplexFormFirst()
+ {
+ Assert.That(DetailSequenceMove.CanMoveUp(_variant1), Is.False, "it would displace the complex form");
+ Assert.That(DetailSequenceMove.CanMoveUp(_variant2), Is.True, "the third ref only moves to second");
+ Assert.That(DetailSequenceMove.CanMoveDown(_complexForm), Is.True, "the rule covers Move Up only, as before");
+
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => _complexForm.ComplexEntryTypesRS.Clear());
+
+ Assert.That(DetailSequenceMove.CanMoveUp(_variant1), Is.True, "a first ref that is no complex form reserves nothing");
+ }
+
+ [Test]
+ public void MoveUp_AndMoveDown_SwapNeighbours_InOneUndoableStep()
+ {
+ Assert.That(DetailSequenceMove.MoveUp(_last, "undo", "redo"), Is.True);
+ Assert.That(_entry.PronunciationsOS.ToList(), Is.EqualTo(new[] { _first, _last, _middle }));
+ Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("undo"));
+ Cache.ActionHandlerAccessor.Undo();
+ Assert.That(_entry.PronunciationsOS.ToList(), Is.EqualTo(new[] { _first, _middle, _last }), "one Undo restores the order");
+
+ Assert.That(DetailSequenceMove.MoveDown(_first, "undo", "redo"), Is.True);
+ Assert.That(_entry.PronunciationsOS.ToList(), Is.EqualTo(new[] { _middle, _first, _last }));
+ Cache.ActionHandlerAccessor.Undo();
+ Assert.That(_entry.PronunciationsOS.ToList(), Is.EqualTo(new[] { _first, _middle, _last }));
+ }
+
+ [Test]
+ public void MoveUp_AndMoveDown_DoNothing_WhereTheRuleRefuses()
+ {
+ Assert.That(DetailSequenceMove.MoveUp(_first, "undo", "redo"), Is.False);
+ Assert.That(DetailSequenceMove.MoveDown(_last, "undo", "redo"), Is.False);
+ Assert.That(DetailSequenceMove.MoveUp(_variant1, "undo", "redo"), Is.False, "the complex form keeps its slot");
+ Assert.That(DetailSequenceMove.MoveUp(_entry, "undo", "redo"), Is.False);
+ Assert.That(_entry.PronunciationsOS.ToList(), Is.EqualTo(new[] { _first, _middle, _last }));
+ Assert.That(_entry.EntryRefsOS.ToList(), Is.EqualTo(new[] { _complexForm, _variant1, _variant2 }));
+ }
+ }
+}
diff --git a/Src/FdoUi/FdoUiTests/DetailRules/MediaFileInsertionTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/MediaFileInsertionTests.cs
new file mode 100644
index 0000000000..3f3ee5a806
--- /dev/null
+++ b/Src/FdoUi/FdoUiTests/DetailRules/MediaFileInsertionTests.cs
@@ -0,0 +1,126 @@
+// 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 Insert Sound or Movie rule: which entry and pronunciation take the files, the
+ /// pronunciation created for an entry without one, and the media links.
+ ///
+ [TestFixture]
+ public class MediaFileInsertionTests : MemoryOnlyBackendProviderTestBase
+ {
+ private ILexEntry _entry;
+ private ILexEntry _bare;
+ private ILexSense _sense;
+ private ILexPronunciation _first;
+ private ILexPronunciation _second;
+
+ // An entry with a sense and two pronunciations, and a bare entry with neither.
+ [SetUp]
+ public void MakeEntries()
+ {
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ _entry = Cache.ServiceLocator.GetInstance().Create();
+ _sense = Cache.ServiceLocator.GetInstance().Create();
+ _entry.SensesOS.Add(_sense);
+ var pronunciations = Cache.ServiceLocator.GetInstance();
+ _entry.PronunciationsOS.Add(_first = pronunciations.Create());
+ _entry.PronunciationsOS.Add(_second = pronunciations.Create());
+ _bare = Cache.ServiceLocator.GetInstance().Create();
+ });
+ }
+
+ [TearDown]
+ public void DeleteEntries()
+ {
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ if (_entry.IsValidObject)
+ _entry.Delete();
+ if (_bare.IsValidObject)
+ _bare.Delete();
+ });
+ }
+
+ [Test]
+ public void TargetEntry_IsTheRowsEntry_ElseTheRootEntry()
+ {
+ Assert.That(MediaFileInsertion.TargetEntry(_second, _bare), Is.SameAs(_entry), "a pronunciation's entry");
+ Assert.That(MediaFileInsertion.TargetEntry(_sense, _bare), Is.SameAs(_entry), "a sense's entry");
+ Assert.That(MediaFileInsertion.TargetEntry(_entry, _bare), Is.SameAs(_entry), "the entry itself");
+ Assert.That(MediaFileInsertion.TargetEntry(Cache.LangProject, _bare), Is.SameAs(_bare), "no entry: the root");
+ Assert.That(MediaFileInsertion.TargetEntry(null, _bare), Is.SameAs(_bare));
+ Assert.That(MediaFileInsertion.TargetEntry(Cache.LangProject, Cache.LangProject), Is.Null);
+ Assert.That(MediaFileInsertion.TargetEntry(null, null), Is.Null);
+ }
+
+ [Test]
+ public void TargetPronunciation_IsTheRowsOwn_ElseTheEntrysFirst_ElseNone()
+ {
+ Assert.That(MediaFileInsertion.TargetPronunciation(_second, null), Is.SameAs(_second));
+ Assert.That(MediaFileInsertion.TargetPronunciation(_sense, null), Is.SameAs(_first));
+ Assert.That(MediaFileInsertion.TargetPronunciation(_entry, null), Is.SameAs(_first));
+ Assert.That(MediaFileInsertion.TargetPronunciation(_bare, null), Is.Null, "the bare entry has none yet");
+ Assert.That(MediaFileInsertion.TargetPronunciation(null, null), Is.Null);
+ }
+
+ [Test]
+ public void FirstOrCreatePronunciation_CreatesOne_OnlyWhenTheEntryHasNone()
+ {
+ Assert.That(MediaFileInsertion.FirstOrCreatePronunciation(_entry, "undo", "redo"), Is.SameAs(_first));
+ Assert.That(_entry.PronunciationsOS, Has.Count.EqualTo(2));
+
+ var created = MediaFileInsertion.FirstOrCreatePronunciation(_bare, "undo", "redo");
+
+ Assert.That(_bare.PronunciationsOS.ToList(), Is.EqualTo(new[] { created }));
+ Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("undo"));
+ Cache.ActionHandlerAccessor.Undo();
+ Assert.That(_bare.PronunciationsOS, Is.Empty, "one Undo removes it");
+ }
+
+ // The dialog lists the last picked file first, so the names land in reverse after the
+ // existing links: two picked files end in pick order.
+ [Test]
+ public void Insert_LinksEachNamedFile_InTheMediaFolder_InOneUndoableStep()
+ {
+ var existing = MediaFileInsertion.Insert(_second, new[] { "existing.wav" }, "undo", "redo").Single();
+
+ var created = MediaFileInsertion.Insert(_second, new[] { "two.mp4", "", "one.wav" }, "undo", "redo");
+
+ Assert.That(_second.MediaFilesOS.ToList(), Is.EqualTo(new[] { existing }.Concat(created)),
+ "after the existing link, the empty name skipped");
+ Assert.That(created.Select(m => m.MediaFileRA.InternalPath), Is.EqualTo(new[] { "one.wav", "two.mp4" }),
+ "in reverse of the names' order");
+ var folder = created[0].MediaFileRA.Owner as ICmFolder;
+ Assert.That(folder?.Name.BestAnalysisAlternative.Text, Is.EqualTo(MediaFileInsertion.MediaFolderName()));
+ Assert.That(folder.Owner, Is.SameAs(Cache.LangProject), "the project's media folder");
+ Assert.That(created[1].MediaFileRA.Owner, Is.SameAs(folder));
+ Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("undo"));
+ Cache.ActionHandlerAccessor.Undo();
+ Assert.That(_second.MediaFilesOS.ToList(), Is.EqualTo(new[] { existing }), "one Undo removes both new links");
+ }
+
+ [Test]
+ public void Insert_DoesNothing_WithoutANamedFile()
+ {
+ Assert.That(MediaFileInsertion.Insert(_second, new[] { "", null }, "undo", "redo"), Is.Empty);
+ Assert.That(_second.MediaFilesOS, Is.Empty);
+ }
+
+ [Test]
+ public void MediaFolderName_IsNeverEmpty()
+ {
+ Assert.That(MediaFileInsertion.MediaFolderName(), Is.Not.Empty);
+ Assert.That(MediaFileInsertion.MediaFolderName(), Does.Not.StartWith("*"), "no unresolved table key");
+ }
+ }
+}
diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs
index 74d8561b40..0156eba444 100644
--- a/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs
+++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs
@@ -9,8 +9,11 @@
using SIL.FieldWorks.Common.Framework.DetailControls;
using SIL.FieldWorks.Common.FwAvalonia.Detail;
using SIL.FieldWorks.Common.FwUtils;
+using SIL.FieldWorks.Common.RootSites;
using SIL.FieldWorks.FdoUi;
+using SIL.FieldWorks.FwCoreDlgs;
using SIL.FieldWorks.LexText.Controls;
+using SIL.FieldWorks.Resources;
using SIL.LCModel;
using SIL.LCModel.Infrastructure;
using SIL.Reporting;
@@ -45,6 +48,18 @@ public partial class RecordEditView
/// sentence.
internal const string LaunchGuiControlMessage = "LaunchGuiControl";
+ /// The message every Move ... Up leaf of the detail menus sends.
+ internal const string MoveUpMessage = "MoveUpObjectInSequence";
+
+ /// The message every Move ... Down leaf of the detail menus sends.
+ internal const string MoveDownMessage = "MoveDownObjectInSequence";
+
+ /// The message of Insert Sound or Movie.
+ internal const string InsertMediaFileMessage = "InsertMediaFile";
+
+ /// The message of Picture Properties.
+ internal const string PicturePropertiesMessage = "PictureProperties";
+
///
/// Asks the user to confirm a delete: the product shows the object's confirmation
/// dialog; a headless test answers for the user.
@@ -79,6 +94,19 @@ public partial class RecordEditView
///
internal Action GuiControlLaunch { get; set; } = (ui, command) => ui.LaunchGuiControl(command);
+ ///
+ /// Picks the sound or movie files to link, by their names under the linked-files
+ /// folder, or an empty list to cancel: the product asks with the file dialog and
+ /// offers to move or copy the files there; a headless test answers for the user.
+ ///
+ internal Func> MediaFilesToInsert { get; set; }
+
+ ///
+ /// Picks the picture's new file, or null to leave it: the product asks with
+ /// ; a headless test answers for the user.
+ ///
+ internal Func PictureFileChosen { get; set; }
+
///
/// The message-keyed authority for the Lexicon menus, owning by predicate and
/// answering for ; with no row every answer is hidden.
@@ -93,6 +121,10 @@ internal LexiconMenuAuthority CreateLexiconMenuAuthority(DetailField field)
authority.Answer(ConvertLexemeFormMessage, leaf => ConvertLexemeFormItem(leaf, field));
authority.Answer(InsertMessage, leaf => InsertItem(leaf, field), InsertLeafInScope);
authority.Answer(LaunchGuiControlMessage, leaf => GuiControlItem(leaf, field));
+ authority.Answer(MoveUpMessage, leaf => MoveItem(leaf, field, up: true));
+ authority.Answer(MoveDownMessage, leaf => MoveItem(leaf, field, up: false));
+ authority.Answer(InsertMediaFileMessage, leaf => InsertMediaItem(leaf, field));
+ authority.Answer(PicturePropertiesMessage, leaf => PicturePropertiesItem(leaf, field));
return authority;
}
@@ -284,9 +316,10 @@ private ICmObject RowObjectOf(DetailField field)
}
///
- /// The Delete item for the row's object: absent on a row with no object or a ghost
- /// row; disabled when the object is gone from the repository or
- /// refuses it.
+ /// The Delete 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 the object is
+ /// gone from the repository or refuses
+ /// it.
///
internal DetailMenuItem DeleteItem(DetailMenuLeaf leaf, DetailField field)
{
@@ -298,6 +331,8 @@ internal DetailMenuItem DeleteItem(DetailMenuLeaf leaf, DetailField field)
Logger.WriteEvent("Delete is disabled: the row's object " + field.ObjectHvo + " no longer exists.");
return DetailMenuItem.Disabled(DetailObjectDeletion.DeleteLabel(label, null, false));
}
+ if (!CommandActsOn(target, leaf.Command))
+ return null;
var canDelete = DetailObjectDeletion.CanDeleteNow(target);
var text = DetailObjectDeletion.DeleteLabel(label, target, canDelete);
return new DetailMenuItem(text, isEnabled: canDelete, isChecked: false, children: null,
@@ -541,5 +576,175 @@ private int ClassIdOf(string className)
var mdc = (IFwMetaDataCacheManaged)Cache.MetaDataCacheAccessor;
return !string.IsNullOrEmpty(className) && mdc.ClassExists(className) ? mdc.GetClassId(className) : 0;
}
+
+ ///
+ /// Whether a Delete or Move command acts on : its
+ /// className is the target's class, or the class of the owner whose
+ /// field holds the target (the picture commands name the sense). A command
+ /// naming no class, or one the model lacks, acts on any object. This keeps the items
+ /// off a section row, whose object is the sequence's owner.
+ ///
+ internal static bool CommandActsOn(ICmObject target, Command command)
+ {
+ var className = command.GetParameter("className", null);
+ if (string.IsNullOrEmpty(className))
+ return true;
+ var cache = target.Cache;
+ var mdc = (IFwMetaDataCacheManaged)cache.MetaDataCacheAccessor;
+ if (!mdc.ClassExists(className))
+ return true;
+ var classId = mdc.GetClassId(className);
+ if (cache.ClassIsOrInheritsFrom(target.ClassID, classId))
+ return true;
+ var owner = target.Owner;
+ var fieldName = command.GetParameter("field", null);
+ return owner != null && !string.IsNullOrEmpty(fieldName) && cache.ClassIsOrInheritsFrom(owner.ClassID, classId)
+ && mdc.GetFieldName(target.OwningFlid) == fieldName;
+ }
+
+ ///
+ /// The Move Up or Move Down item for the row's object: absent on a row with no
+ /// object, a ghost row, a row whose object the command does not act on, and an object
+ /// outside an owning sequence; disabled at the end it would move past.
+ ///
+ /// True for Move Up, false for Move Down.
+ internal DetailMenuItem MoveItem(DetailMenuLeaf leaf, DetailField field, bool up)
+ {
+ var target = RowObjectOf(field);
+ if (target == null || field.GhostPrompt != null || !CommandActsOn(target, leaf.Command)
+ || !DetailSequenceMove.IsInSequence(target))
+ {
+ return null;
+ }
+ var label = XCoreMenuBridge.StripAccelerator(leaf.Label);
+ if (!(up ? DetailSequenceMove.CanMoveUp(target) : DetailSequenceMove.CanMoveDown(target)))
+ return DetailMenuItem.Disabled(label);
+ return new DetailMenuItem(label, isEnabled: true, isChecked: false, children: null,
+ execute: () => MoveDetailObject(field, up));
+ }
+
+ ///
+ /// Moves the row's object one place up or down in its owning sequence: pending edits
+ /// settle first, the move is one undoable step, and the re-show keeps the keyboard on
+ /// the moved object's rows.
+ ///
+ /// True when the object moved.
+ internal bool MoveDetailObject(DetailField field, bool up)
+ {
+ SettleDetailEdits();
+ var target = RowObjectOf(field);
+ if (target == null)
+ {
+ Logger.WriteEvent("Move did nothing: the row's object " + field?.ObjectHvo + " no longer exists.");
+ return false;
+ }
+ var moved = up
+ ? DetailSequenceMove.MoveUp(target, xWorksStrings.UndoMoveItem, xWorksStrings.RedoMoveItem)
+ : DetailSequenceMove.MoveDown(target, xWorksStrings.UndoMoveItem, xWorksStrings.RedoMoveItem);
+ if (!moved)
+ return false;
+ m_avaloniaEntryForm?.FocusObjectOnNextShow(target.Hvo);
+ OnAvaloniaDetailEditCompleted(this, EventArgs.Empty);
+ return true;
+ }
+
+ ///
+ /// The Insert Sound or Movie item for the row: absent with no row or no object, and
+ /// when neither the row's object nor the shown record belongs to an entry; enabled
+ /// otherwise. The files go to the row's pronunciation, else the entry's first, created
+ /// when it has none.
+ ///
+ internal DetailMenuItem InsertMediaItem(DetailMenuLeaf leaf, DetailField field)
+ {
+ var rowObject = RowObjectOf(field);
+ if (rowObject == null || MediaFileInsertion.TargetEntry(rowObject, ResolveShownRecord(Clerk?.CurrentObject)) == null)
+ return null;
+ return new DetailMenuItem(XCoreMenuBridge.StripAccelerator(leaf.Label), isEnabled: true, isChecked: false,
+ children: null, execute: () => InsertMediaFiles(field));
+ }
+
+ ///
+ /// Links the files the user picks to the row's pronunciation, else to the entry's
+ /// first, created once the user has picked: pending edits settle first, the
+ /// pronunciation and its links are one undoable step, and the re-show focuses the
+ /// pronunciation.
+ ///
+ /// The media objects created.
+ internal IReadOnlyList InsertMediaFiles(DetailField field)
+ {
+ SettleDetailEdits();
+ var root = ResolveShownRecord(Clerk?.CurrentObject);
+ var rowObject = RowObjectOf(field);
+ var entry = MediaFileInsertion.TargetEntry(rowObject, root);
+ if (entry == null)
+ {
+ Logger.WriteEvent("Insert Sound or Movie did nothing: no entry for the row's object " + field?.ObjectHvo + ".");
+ return new ICmMedia[0];
+ }
+ var files = (MediaFilesToInsert ?? ChooseMediaFilesWithDialog)();
+ if (files == null || files.Count == 0)
+ return new ICmMedia[0];
+ ILexPronunciation pronunciation = null;
+ IReadOnlyList created = null;
+ UndoableUnitOfWorkHelper.Do(xWorksStrings.ksUndoInsertMedia, xWorksStrings.ksRedoInsertMedia, Cache.ActionHandlerAccessor, () =>
+ {
+ pronunciation = MediaFileInsertion.TargetPronunciation(rowObject, root)
+ ?? MediaFileInsertion.FirstOrCreatePronunciation(entry, xWorksStrings.ksUndoCreatePronunciation,
+ xWorksStrings.ksRedoCreatePronunciation);
+ created = MediaFileInsertion.Insert(pronunciation, files, xWorksStrings.ksUndoInsertMedia, xWorksStrings.ksRedoInsertMedia);
+ });
+ m_avaloniaEntryForm?.FocusObjectOnNextShow(pronunciation.Hvo);
+ OnAvaloniaDetailEditCompleted(this, EventArgs.Empty);
+ return created;
+ }
+
+ private IReadOnlyList ChooseMediaFilesWithDialog()
+ => DTMenuHandler.ChooseMediaFiles(Cache, m_propertyTable,
+ ResourceHelper.BuildFileFilter(FileFilterType.AllAudio, FileFilterType.AllVideo, FileFilterType.AllFiles),
+ xWorksStrings.ChooseSoundOrMovieFile);
+
+ ///
+ /// The Picture Properties item for the row: absent unless the row's object is a
+ /// picture, enabled then.
+ ///
+ internal DetailMenuItem PicturePropertiesItem(DetailMenuLeaf leaf, DetailField field)
+ {
+ if (!(RowObjectOf(field) is ICmPicture))
+ return null;
+ return new DetailMenuItem(XCoreMenuBridge.StripAccelerator(leaf.Label), isEnabled: true, isChecked: false,
+ children: null, execute: () => EditPictureProperties(field));
+ }
+
+ ///
+ /// Points the row's picture at the file the user picks: pending edits settle first,
+ /// the update is one undoable step, and the view re-shows.
+ ///
+ /// True when the picture changed.
+ internal bool EditPictureProperties(DetailField field)
+ {
+ SettleDetailEdits();
+ if (!(RowObjectOf(field) is ICmPicture picture))
+ {
+ Logger.WriteEvent("Picture Properties did nothing: the row's object " + field?.ObjectHvo + " is no picture.");
+ return false;
+ }
+ var file = (PictureFileChosen ?? ChoosePictureFileWithDialog)(picture);
+ if (string.IsNullOrEmpty(file))
+ return false;
+ UndoableUnitOfWorkHelper.Do(xWorksStrings.ksUndoUpdatePicture, xWorksStrings.ksRedoUpdatePicture, picture,
+ () => picture.UpdatePicture(file, null, CmFolderTags.DefaultPictureFolder, 0));
+ OnAvaloniaDetailEditCompleted(this, EventArgs.Empty);
+ return true;
+ }
+
+ private string ChoosePictureFileWithDialog(ICmPicture picture)
+ {
+ using (var dlg = new PicturePropertiesDialog(Cache, picture, m_propertyTable.GetValue("HelpTopicProvider"),
+ m_propertyTable.GetValue("App")))
+ {
+ dlg.Initialize();
+ return dlg.ShowDialog(m_propertyTable.GetValue