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
("window")) == DialogResult.OK ? dlg.CurrentFile : null; + } + } } } diff --git a/Src/xWorks/DTMenuHandler.cs b/Src/xWorks/DTMenuHandler.cs index 6078374598..925b15833a 100644 --- a/Src/xWorks/DTMenuHandler.cs +++ b/Src/xWorks/DTMenuHandler.cs @@ -291,100 +291,59 @@ public bool OnInsertMediaFile(object cmd) { string filter = ResourceHelper.BuildFileFilter(FileFilterType.AllAudio, FileFilterType.AllVideo, FileFilterType.AllFiles); - string keyCaption = "kstidInsertMediaChooseFileCaption"; - string defaultCaption = xWorksStrings.ChooseSoundOrMovieFile; - return InsertMediaFile(cmd, filter, keyCaption, defaultCaption); + return InsertMediaFile(cmd, filter, xWorksStrings.ChooseSoundOrMovieFile); } - private bool InsertMediaFile(object cmd, string filter, string keyCaption, string defaultCaption) + private bool InsertMediaFile(object cmd, string filter, string caption) { - ICmObject obj; - int flid; - bool fOnPronunciationSlice = CanInsertPictureOrMediaFile(cmd, out flid); - if (fOnPronunciationSlice) + ILexPronunciation pronunciation; + if (CanInsertPictureOrMediaFile(cmd, out _)) { - obj = m_dataEntryForm.CurrentSlice.Object; + pronunciation = (ILexPronunciation)m_dataEntryForm.CurrentSlice.Object; } else { - // Find the first pronunciation object on the current entry, creating it if - // necessary. + // Not on a pronunciation: the entry's first, created when it has none. var le = m_dataEntryForm.Root as ILexEntry; if (le == null) return false; - if (le.PronunciationsOS.Count == 0) - { - // Ensure that the pronunciation writing systems have been initialized. - // Otherwise, the crash reported in FWR-2086 can happen! - int wsPron = Cache.DefaultPronunciationWs; - UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Undo Create Pronuciation", - "Redo Create Pronunciation", Cache.ActionHandlerAccessor, () => - { - le.PronunciationsOS.Add(Cache.ServiceLocator.GetInstance().Create()); - }); - } - obj = le.PronunciationsOS[0]; - flid = LexPronunciationTags.kflidMediaFiles; + pronunciation = MediaFileInsertion.FirstOrCreatePronunciation(le, xWorksStrings.ksUndoCreatePronunciation, + xWorksStrings.ksRedoCreatePronunciation); } + string[] fileNames = ChooseMediaFiles(Cache, m_propertyTable, filter, caption); + if (fileNames.Length == 0 || !pronunciation.IsValidObject) + return true; // cancelled, or another client deleted it while we were choosing the file + MediaFileInsertion.Insert(pronunciation, fileNames, xWorksStrings.ksUndoInsertMedia, xWorksStrings.ksRedoInsertMedia); + return true; + } + + /// + /// Asks the user for sound or movie files and moves or copies them into the + /// linked-files folder as the user chooses, remembering the directory for next time. + /// + /// The files' names under the linked-files folder; empty when the user + /// cancels. + internal static string[] ChooseMediaFiles(LcmCache cache, PropertyTable propertyTable, string filter, string caption) + { using (var dlg = new OpenFileDialogAdapter()) { - string defaultDirectory = Cache.LangProject.LinkedFilesRootDir; - dlg.InitialDirectory = m_mediator != null ? m_propertyTable.GetStringProperty("InsertMediaFile-LastDirectory", defaultDirectory) - : defaultDirectory; + string defaultDirectory = cache.LangProject.LinkedFilesRootDir; + dlg.InitialDirectory = propertyTable.GetStringProperty("InsertMediaFile-LastDirectory", defaultDirectory); dlg.Filter = filter; dlg.FilterIndex = 1; - if (string.IsNullOrEmpty(dlg.Title) || dlg.Title == "*" + keyCaption + "*") - { - dlg.Title = defaultCaption; - } + dlg.Title = caption; dlg.RestoreDirectory = true; dlg.CheckFileExists = true; dlg.CheckPathExists = true; dlg.Multiselect = true; - - DialogResult dialogResult = DialogResult.None; - while (dialogResult != DialogResult.OK && dialogResult != DialogResult.Cancel) - { - dialogResult = dlg.ShowDialog(); - if (dialogResult == DialogResult.OK) - { - string[] fileNames = MoveOrCopyFilesController.MoveCopyOrLeaveMediaFiles(dlg.FileNames, - Cache.LangProject.LinkedFilesRootDir, m_propertyTable.GetValue("HelpTopicProvider")); - string sFolderName = StringTable.Table.GetString("kstidMediaFolder"); - if (string.IsNullOrEmpty(sFolderName) || sFolderName == "*kstidMediaFolder*") - { - sFolderName = CmFolderTags.LocalMedia; - } - if (!obj.IsValidObject) - return true; // Probably some other client deleted it while we were choosing the file. - - int chvo = obj.Cache.DomainDataByFlid.get_VecSize(obj.Hvo, flid); - UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW( - xWorksStrings.ksUndoInsertMedia, xWorksStrings.ksRedoInsertMedia, - Cache.ActionHandlerAccessor, () => - { - foreach (string fileName in fileNames.Where(f => !string.IsNullOrEmpty(f))) - { - int hvo = obj.Cache.DomainDataByFlid.MakeNewObject(CmMediaTags.kClassId, obj.Hvo, flid, chvo); - ICmMedia media = Cache.ServiceLocator.GetInstance().GetObject(hvo); - ICmFolder folder = DomainObjectServices.FindOrCreateFolder(obj.Cache, LangProjectTags.kflidMedia, sFolderName); - media.MediaFileRA = DomainObjectServices.FindOrCreateFile(folder, fileName); - } - }); - - if (m_mediator != null) - { - string fileName = dlg.FileNames.FirstOrDefault(f => !string.IsNullOrEmpty(f)); - if (fileName != null) - { - string directory = Path.GetDirectoryName(fileName); - m_propertyTable.SetProperty("InsertMediaFile-LastDirectory", directory, false); - } - } - } - } + if (dlg.ShowDialog() != DialogResult.OK) + return new string[0]; + string fileName = dlg.FileNames.FirstOrDefault(f => !string.IsNullOrEmpty(f)); + if (fileName != null) + propertyTable.SetProperty("InsertMediaFile-LastDirectory", Path.GetDirectoryName(fileName), false); + return MoveOrCopyFilesController.MoveCopyOrLeaveMediaFiles(dlg.FileNames, cache.LangProject.LinkedFilesRootDir, + propertyTable.GetValue("HelpTopicProvider")); } - return true; } /// @@ -1111,19 +1070,7 @@ public bool OnMoveUpObjectInSequence(object cmd) Debug.Assert(slice != null, "No slice was current"); Debug.Assert(!slice.IsDisposed, "The current slice is already disposed??"); if (slice != null) - { - var cache = m_dataEntryForm.Cache; - var obj = slice.Object.Owner; - int flid = slice.Object.OwningFlid; - int ihvo = cache.DomainDataByFlid.GetObjIndex(obj.Hvo, (int)flid, slice.Object.Hvo); - if (ihvo > 0) - { - // We found it in the sequence, and it isn't already the first. - UndoableUnitOfWorkHelper.Do(xWorksStrings.UndoMoveItem, xWorksStrings.RedoMoveItem, cache.ActionHandlerAccessor, - ()=>cache.DomainDataByFlid.MoveOwnSeq(obj.Hvo, (int)flid, ihvo, ihvo, - obj.Hvo, (int)flid, ihvo - 1)); - } - } + DetailSequenceMove.MoveUp(slice.Object, xWorksStrings.UndoMoveItem, xWorksStrings.RedoMoveItem); return true; //we handled this. } @@ -1138,45 +1085,14 @@ public virtual bool OnDisplayMoveUpObjectInSequence(object commandObject, { Slice slice = m_dataEntryForm.CurrentSlice; Debug.Assert(slice == null || !slice.IsDisposed, "The current slice is already disposed??"); - if (slice == null || slice.Object == null) + var target = slice?.Object; + if (!DetailSequenceMove.IsInSequence(target)) { display.Enabled = false; display.Visible = false; return true; } - var cache = m_dataEntryForm.Cache; - // FWR-2742 Handle a slice Object (like LexEntry) being unowned (and OwningFlid = 0) - var type = CellarPropertyType.ReferenceAtomic; - if (slice.Object.OwningFlid > 0) - type = (CellarPropertyType) cache.DomainDataByFlid.MetaDataCache.GetFieldType(slice.Object.OwningFlid); - if (type != CellarPropertyType.OwningSequence && type != CellarPropertyType.ReferenceSequence) - { - display.Enabled = false; - display.Visible = false; - return true; - } - int chvo = cache.DomainDataByFlid.get_VecSize(slice.Object.Owner.Hvo, (int)slice.Object.OwningFlid); - if (chvo < 2) - { - display.Enabled = false; - } - else - { - int hvo = cache.DomainDataByFlid.get_VecItem(slice.Object.Owner.Hvo, (int)slice.Object.OwningFlid, - 0); - display.Enabled = slice.Object.Hvo != hvo; - // if the first LexEntryRef in LexEntry.EntryRefs is a complex form, and the - // slice displays the second LexEntryRef in the sequence, then we can't move it - // up, since the first slot is reserved for the complex form. - if (display.Enabled && slice.Object.OwningFlid == LexEntryTags.kflidEntryRefs) - { - if (cache.DomainDataByFlid.get_VecSize(hvo, LexEntryRefTags.kflidComplexEntryTypes) > 0) - { - int hvo1 = cache.DomainDataByFlid.get_VecItem(slice.Object.Owner.Hvo, (int)slice.Object.OwningFlid, 1); - display.Enabled = slice.Object.Hvo != hvo1; - } - } - } + display.Enabled = DetailSequenceMove.CanMoveUp(target); display.Visible = true; return true; //we've handled this } @@ -1192,23 +1108,7 @@ public virtual bool OnMoveDownObjectInSequence(object cmd) Debug.Assert(slice != null, "No slice was current"); Debug.Assert(!slice.IsDisposed, "The current slice is already disposed??"); if (slice != null) - { - var cache = m_dataEntryForm.Cache; - int hvoOwner = slice.Object.Owner.Hvo; - int flid = slice.Object.OwningFlid; - int chvo = cache.DomainDataByFlid.get_VecSize(hvoOwner, (int)flid); - int ihvo = cache.DomainDataByFlid.GetObjIndex(hvoOwner, (int)flid, slice.Object.Hvo); - if (ihvo >= 0 && ihvo + 1 < chvo) - { - // We found it in the sequence, and it isn't already the last. - // Quoting from VwOleDbDa.cpp, "Insert the selected records before the - // DstStart object". This means we need + 2 instead of + 1 for the - // new location. - UndoableUnitOfWorkHelper.Do(xWorksStrings.UndoMoveItem, xWorksStrings.RedoMoveItem, cache.ActionHandlerAccessor, - ()=>cache.DomainDataByFlid.MoveOwnSeq(hvoOwner, (int)flid, ihvo, ihvo, - hvoOwner, (int)flid, ihvo + 2)); - } - } + DetailSequenceMove.MoveDown(slice.Object, xWorksStrings.UndoMoveItem, xWorksStrings.RedoMoveItem); return true; //we handled this. } @@ -1223,35 +1123,14 @@ public virtual bool OnDisplayMoveDownObjectInSequence(object commandObject, { Slice slice = m_dataEntryForm.CurrentSlice; Debug.Assert(slice == null || !slice.IsDisposed, "The current slice is already disposed??"); - if (slice == null || slice.Object == null) + var target = slice?.Object; + if (!DetailSequenceMove.IsInSequence(target)) { display.Enabled = false; display.Visible = false; return true; } - LcmCache cache = m_dataEntryForm.Cache; - IFwMetaDataCache mdc = cache.DomainDataByFlid.MetaDataCache; - // FWR-2742 Handle a slice Object (like LexEntry) being unowned (and OwningFlid = 0) - var type = CellarPropertyType.ReferenceAtomic; - if (slice.Object.OwningFlid > 0) - type = (CellarPropertyType)cache.DomainDataByFlid.MetaDataCache.GetFieldType(slice.Object.OwningFlid); - if (type != CellarPropertyType.OwningSequence && type != CellarPropertyType.ReferenceSequence) - { - display.Enabled = false; - display.Visible = false; - return true; - } - int chvo = cache.DomainDataByFlid.get_VecSize(slice.Object.Owner.Hvo, (int)slice.Object.OwningFlid); - if (chvo < 2) - { - display.Enabled = false; - } - else - { - int hvo = cache.DomainDataByFlid.get_VecItem(slice.Object.Owner.Hvo, (int)slice.Object.OwningFlid, - chvo - 1); - display.Enabled = slice.Object.Hvo != hvo; - } + display.Enabled = DetailSequenceMove.CanMoveDown(target); display.Visible = true; return true; //we've handled this } diff --git a/Src/xWorks/xWorksStrings.Designer.cs b/Src/xWorks/xWorksStrings.Designer.cs index 1238be6046..7bd4dc04df 100644 --- a/Src/xWorks/xWorksStrings.Designer.cs +++ b/Src/xWorks/xWorksStrings.Designer.cs @@ -2023,7 +2023,25 @@ internal static string ksRedoInsertPicture { return ResourceManager.GetString("ksRedoInsertPicture", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Redo Update Picture. + /// + internal static string ksRedoUpdatePicture { + get { + return ResourceManager.GetString("ksRedoUpdatePicture", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Redo Create Pronunciation. + /// + internal static string ksRedoCreatePronunciation { + get { + return ResourceManager.GetString("ksRedoCreatePronunciation", resourceCulture); + } + } + /// /// Looks up a localized string similar to Redo {0}. /// @@ -2418,7 +2436,25 @@ internal static string ksUndoInsertPicture { return ResourceManager.GetString("ksUndoInsertPicture", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Undo Update Picture. + /// + internal static string ksUndoUpdatePicture { + get { + return ResourceManager.GetString("ksUndoUpdatePicture", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Undo Create Pronunciation. + /// + internal static string ksUndoCreatePronunciation { + get { + return ResourceManager.GetString("ksUndoCreatePronunciation", resourceCulture); + } + } + /// /// Looks up a localized string similar to Undo {0}. /// diff --git a/Src/xWorks/xWorksStrings.resx b/Src/xWorks/xWorksStrings.resx index 0eccab62f6..9607b91c73 100644 --- a/Src/xWorks/xWorksStrings.resx +++ b/Src/xWorks/xWorksStrings.resx @@ -540,6 +540,18 @@ Are you SURE you want to delete the field {2}? Undo Insert Picture + + Redo Update Picture + + + Undo Update Picture + + + Redo Create Pronunciation + + + Undo Create Pronunciation + Choose dictionary layout to configure: diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs index 21873ad052..c4a6ec0e21 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs @@ -16,6 +16,7 @@ using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; using SIL.LCModel.Core.Text; +using SIL.LCModel.DomainServices; using SIL.LCModel.Infrastructure; using XCore; using LegacyDataTree = SIL.FieldWorks.Common.Framework.DetailControls.DataTree; @@ -50,6 +51,10 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase // Allomorphs and Variants hotlinks. "mnuDataTree-AlternateForms", "mnuDataTree-VariantForms", "mnuDataTree-Examples", "mnuDataTree-AlternateForms-Hotlinks", "mnuDataTree-VariantForms-Hotlinks", + // Lexicon rows: the item menus of etymology, extended note, note examples, example, + // pronunciation and Variant Info; the picture menu, no Avalonia row yet (LT-22772). + "mnuDataTree-Etymology", "mnuDataTree-ExtendedNote", "mnuDataTree-ExtendedNote-Examples", + "mnuDataTree-Example", "mnuDataTree-Pronunciation", "mnuDataTree-VariantSpec", "mnuDataTree-Picture", // Lexicon: an id no part reaches (its pronunciation part does not exist). "mnuDataTree-CmMedia", // Grammar: posEdit rows (two item rows, five section rows: Affix Templates, Affix @@ -75,7 +80,9 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase { "mnuDataTree-Translation", "mnuDataTree-ComplexFormSpec", "mnuDataTree-LexemeForm", "mnuDataTree-Subsenses", "mnuDataTree-AlternateForms", "mnuDataTree-VariantForms", - "mnuDataTree-Examples" + "mnuDataTree-Examples", "mnuDataTree-Etymology", "mnuDataTree-ExtendedNote", + "mnuDataTree-ExtendedNote-Examples", "mnuDataTree-Example", "mnuDataTree-Pronunciation", + "mnuDataTree-VariantSpec" }; private static readonly string[] LexiconInStringMenuIds = @@ -103,7 +110,12 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase private ILexEntry m_variant; private ICmTranslation m_translation; private ILexExtendedNote m_note; + private ILexExtendedNote m_note2; private ILexEtymology m_etymology; + private ILexEtymology m_etymology2; + private ILexPronunciation m_pronunciation; + private ICmPicture m_picture; + private ILexEntryRef m_variantRef; private RecordEditView m_view; protected override void Init() @@ -272,13 +284,24 @@ public void OwnedMenu_RendersWhatTheAdapterPathRendered_OnEveryLexiconRowThatBin row.Label + ": " + menuId + " offers a command that runs"); if (menuId == "mnuDataTree-ExtendedNote-Hotlinks") { - // The one accepted divergence: the adapter path disables the insert under - // a note, since neither the entry nor the note has ExtendedNote; natively + // An accepted divergence: the adapter path disables the insert under a + // note, since neither the entry nor the note has ExtendedNote; natively // the sense that owns the note takes it. Assert.That(before, Is.EqualTo("Insert Extended Note [enabled=False checked=False executes=False]")); Assert.That(after, Is.EqualTo("Insert Extended Note [enabled=True checked=False executes=True]")); continue; } + if (RowIsTheSequencesOwner(row, menuId)) + { + // A section row and an empty sequence's prompt row carry the item id with the + // OWNER as their object; the adapter path offers the owner's Delete and Move + // there, natively absent. The rest is the same. + Assert.That(before, Does.Contain("Delete ").Or.Contain("Move "), + row.Label + ": the adapter path offers the owner's Delete or Move"); + Assert.That(ItemLines(after), Is.EqualTo(ItemLines(before).Where(l => !l.StartsWith("Delete ") && !l.StartsWith("Move "))), + row.Label + " (" + menuId + ")"); + continue; + } if (!string.Equals(before, after, StringComparison.Ordinal)) { mismatches.Add(string.Format("{0} ({1}):{2}--- adapter path{2}{3}{2}--- authority{2}{4}", @@ -294,8 +317,8 @@ public void OwnedMenus_AreBuiltWithoutTheAdapterOrTheMediator() { Assert.That(HiddenTreeExists, Is.False, "precondition: no hidden tree yet"); var rows = OwnedMenuRows(); - Assert.That(rows, Has.Count.EqualTo(17), - "precondition: fifteen ids, -Translation and -VariantForms on two rows each"); + Assert.That(rows, Has.Count.EqualTo(35), + "precondition: twenty-one ids; -Translation and -VariantForms on two rows each, the item ids and hotlinks on every item's rows"); foreach (var (row, menuId, kind) in rows) { var ids = kind == DetailMenuKind.ContextMenu @@ -341,17 +364,18 @@ public void DeleteLeaf_IsDisabled_WhenTheObjectIsGone_AndWhenTheRuleRefuses() EditorClassification.Known, null, null, HostRouting.Inherit, null, null, null, menuId: translation.MenuId, objectHvo: int.MaxValue - 1); // A sense of an entry without entry refs sits in a required field: the only one - // stays. + // stays. The sense's own Delete command names its class. ILexEntry plain = null; NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => plain = AddLexeme(m_createdObjects, "plain", GetMorphTypeOrCreateOne("stem"), "plain gloss", GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA))); var onlySense = new DetailField("Sense", "Sense", "Senses", null, DetailFieldKind.Header, EditorClassification.GroupingNone, null, null, HostRouting.Inherit, null, null, null, - menuId: translation.MenuId, objectHvo: plain.SensesOS[0].Hvo); + menuId: "mnuDataTree-Sense", objectHvo: plain.SensesOS[0].Hvo); + var deleteSense = Leaf("mnuDataTree-Sense", "CmdDataTree-Delete-Sense"); var goneItem = m_view.DeleteItem(leaf, gone); - var refused = m_view.DeleteItem(leaf, onlySense); + var refused = m_view.DeleteItem(deleteSense, onlySense); Assert.That(goneItem.IsEnabled, Is.False); Assert.That(goneItem.Execute, Is.Null); @@ -494,6 +518,137 @@ public void VariantInsert_IsOfferedForTheEntry_AndAbsentWithoutAnObject() Assert.That(m_view.InsertItem(leaf, noObject), Is.Null); } + // Move follows the object's position in its sequence. The items are absent on the + // section row and the prompt row, whose object is the sequence's owner, and with no row. + [Test] + public void MoveLeaves_FollowThePosition_AndAreAbsentOffAnItemsOwnRows() + { + var up = Leaf("mnuDataTree-Etymology", "CmdDataTree-MoveUp-Etymology"); + var down = Leaf("mnuDataTree-Etymology", "CmdDataTree-MoveDown-Etymology"); + var first = Rows().First(f => f.ObjectHvo == m_etymology.Hvo); + var second = Rows().First(f => f.ObjectHvo == m_etymology2.Hvo); + var pronunciations = Rows().Single(f => f.MenuId == "mnuDataTree-Pronunciation" && f.ObjectHvo == m_entry.Hvo); + var noteExamples = Rows().Where(f => f.MenuId == "mnuDataTree-ExtendedNote-Examples").ToList(); + var moveUpPronunciation = Leaf("mnuDataTree-Pronunciation", "CmdDataTree-MoveUp-Pronunciation"); + var moveUpNoteExample = Leaf("mnuDataTree-ExtendedNote-Examples", "CmdDataTree-MoveUp-ExampleInNote"); + + Assert.That(m_view.MoveItem(up, first, up: true).IsEnabled, Is.False, "the first cannot move up"); + Assert.That(m_view.MoveItem(up, first, up: true).Label, Is.EqualTo("Move Etymology Up")); + Assert.That(m_view.MoveItem(down, first, up: false).IsEnabled, Is.True); + Assert.That(m_view.MoveItem(up, second, up: true).IsEnabled, Is.True); + Assert.That(m_view.MoveItem(down, second, up: false).IsEnabled, Is.False, "the last cannot move down"); + Assert.That(m_view.MoveItem(moveUpPronunciation, pronunciations, up: true), Is.Null, "the section row's object is the entry"); + Assert.That(noteExamples.Select(f => f.GhostPrompt != null), Is.EquivalentTo(new[] { false, true }), + "precondition: the banner over a note's example, and an empty note's prompt row"); + foreach (var row in noteExamples) + Assert.That(m_view.MoveItem(moveUpNoteExample, row, up: true), Is.Null, row.Label + ": the row's object is the note"); + Assert.That(m_view.MoveItem(up, null, up: true), Is.Null, "no row"); + } + + // Section and prompt rows carry the item id with the owner as their object, so Delete + // does not act there. A command may name the item's class or the owner's with the field. + [Test] + public void DeleteLeaf_IsAbsentWhereTheRowsObjectIsTheOwner() + { + var deletePronunciation = Leaf("mnuDataTree-Pronunciation", "CmdDataTree-Delete-Pronunciation"); + var deleteNoteExample = Leaf("mnuDataTree-ExtendedNote-Examples", "CmdDataTree-Delete-ExampleInNote"); + var deletePicture = Leaf("mnuDataTree-Picture", "CmdDataTree-Delete-Picture"); + var deleteVariantInfo = Leaf("mnuDataTree-VariantSpec", "CmdDataTree-Delete-VariantSpec"); + var section = Rows().Single(f => f.MenuId == "mnuDataTree-Pronunciation" && f.ObjectHvo == m_entry.Hvo); + var formRow = Rows().Single(f => f.Field == "Form" && f.ObjectHvo == m_pronunciation.Hvo); + var banner = Rows().Single(f => f.MenuId == "mnuDataTree-ExtendedNote-Examples" && f.GhostPrompt == null); + + Assert.That(m_view.DeleteItem(deletePronunciation, section), Is.Null, "its object is the entry"); + Assert.That(m_view.DeleteItem(deletePronunciation, formRow)?.IsEnabled, Is.True); + Assert.That(m_view.DeleteItem(deleteNoteExample, banner), Is.Null, "its object is the note"); + Assert.That(RecordEditView.CommandActsOn(m_entry, deletePronunciation.Command), Is.False); + Assert.That(RecordEditView.CommandActsOn(m_pronunciation, deletePronunciation.Command), Is.True); + Assert.That(RecordEditView.CommandActsOn(m_picture, deletePicture.Command), Is.True, + "the picture commands name the owning sense and its Pictures field"); + Assert.That(RecordEditView.CommandActsOn(m_entry.SensesOS[0], deletePicture.Command), Is.True, "the sense is of the class"); + Assert.That(RecordEditView.CommandActsOn(m_entry, deleteVariantInfo.Command), Is.True, + "a command naming no class acts on any object"); + Assert.That(RecordEditView.CommandActsOn(m_entry, + Leaf("mnuDataTree-MoveReversalIndexEntry", "CmdDataTree-Delete-Subentry").Command), Is.True, + "a class the model lacks (that command misspells ReversalIndexEntry) hides nothing"); + } + + // The first EntryRefs slot is the complex form's, so the variant ref behind it cannot + // move up. + [Test] + public void VariantInfoMoveUp_IsDisabled_BehindTheComplexForm() + { + var up = Leaf("mnuDataTree-VariantSpec", "CmdDataTree-MoveUp-VariantSpec"); + var down = Leaf("mnuDataTree-VariantSpec", "CmdDataTree-MoveDown-VariantSpec"); + var variantInfo = Rows().Single(f => f.MenuId == "mnuDataTree-VariantSpec"); + Assert.That(variantInfo.ObjectHvo, Is.EqualTo(m_variantRef.Hvo), "precondition: the Variant Type row of the entry's own ref"); + Assert.That(m_entry.EntryRefsOS.IndexOf(m_variantRef), Is.EqualTo(1), "precondition: behind the complex form"); + + Assert.That(m_view.MoveItem(up, variantInfo, up: true).IsEnabled, Is.False); + Assert.That(m_view.MoveItem(up, variantInfo, up: true).Label, Is.EqualTo("Move Variant Info Up")); + Assert.That(m_view.MoveItem(down, variantInfo, up: false).IsEnabled, Is.False, "it is also last"); + } + + // Picture Properties is offered only for a picture. No Avalonia row shows one yet + // (LT-22772), so the menu is proved on a built row against the adapter. + [Test] + public void PictureMenu_IsOfferedForAPicture_AndRendersWhatTheAdapterPathRendered() + { + var leaf = Leaf("mnuDataTree-Picture", "CmdDataTree-Properties-Picture"); + var row = PictureRow(); + + Assert.That(m_view.PicturePropertiesItem(leaf, row)?.IsEnabled, Is.True); + Assert.That(m_view.PicturePropertiesItem(leaf, row).Label, Is.EqualTo("Picture Properties")); + Assert.That(m_view.PicturePropertiesItem(leaf, TranslationRow()), Is.Null, "no picture"); + Assert.That(m_view.PicturePropertiesItem(leaf, null), Is.Null, "no row"); + + EnsureAdapter(m_picture.Hvo, "Caption"); + Assert.That(AdapterTree.CurrentSlice?.Object?.Hvo, Is.EqualTo(m_picture.Hvo), "precondition: the adapter renders the picture"); + var ids = new[] { "mnuDataTree-Picture" }; + var before = Describe(XCoreMenuBridge.CreateMenuItems(Window, ids, null, null, null)); + var after = Describe(XCoreMenuBridge.CreateMenuItems(Window, ids, null, null, + m_view.CreateMenuAuthority(MenuRequest(row, DetailMenuKind.SliceMenu)))); + + Assert.That(after, Is.EqualTo(before)); + Assert.That(after, Does.Contain("Picture Properties [enabled=True checked=False executes=True]")); + Assert.That(after, Does.Contain("Move Picture Up [enabled=False")); + Assert.That(after, Does.Contain("Move Picture Down [enabled=True")); + Assert.That(after, Does.Contain("Delete Picture [enabled=True")); + } + + // Tripwire: fails once sense pictures compose, so the synthetic picture rows give way to + // the real ones. + [Test] + public void NoAvaloniaRowBindsThePictureMenu_UntilPicturesCompose() + { + Assert.That(m_entry.SensesOS[0].PicturesOS, Has.Count.EqualTo(2), "precondition: the sense has pictures"); + + var pictureRows = Rows().Where(f => f.MenuId == "mnuDataTree-Picture").ToList(); + + Assert.That(pictureRows, Is.Empty, + "Sense pictures now compose (LT-22772). Prove mnuDataTree-Picture on the real rows: add it to " + + "LexiconLabelMenuIds so the equivalence and no-mediator contracts cover it, replace PictureRow() " + + "and the built picture row in LexiconMenuExecutionTests with the composed one, drop the comments " + + "tagged LT-22772 in both files, then delete this test."); + } + + [Test] + public void InsertMediaLeaf_IsOfferedOnEveryRowOfAnEntry_AndAbsentWithoutAnObject() + { + var leaf = Leaf("mnuDataTree-Pronunciation", "CmdInsertMediaFile"); + var formRow = Rows().Single(f => f.Field == "Form" && f.ObjectHvo == m_pronunciation.Hvo); + var section = Rows().Single(f => f.MenuId == "mnuDataTree-Pronunciation" && f.ObjectHvo == m_entry.Hvo); + var noObject = new DetailField("Row", "Row", "Pronunciations", null, DetailFieldKind.Header, + EditorClassification.GroupingNone, null, null, HostRouting.Inherit, null, null, null, menuId: section.MenuId); + + Assert.That(m_view.InsertMediaItem(leaf, formRow)?.IsEnabled, Is.True); + Assert.That(m_view.InsertMediaItem(leaf, formRow).Label, Is.EqualTo("Insert Sound or Movie")); + Assert.That(m_view.InsertMediaItem(leaf, section)?.IsEnabled, Is.True, "the entry's first pronunciation takes the files"); + Assert.That(m_view.InsertMediaItem(leaf, TranslationRow())?.IsEnabled, Is.True, "any row of the entry"); + Assert.That(m_view.InsertMediaItem(leaf, noObject), Is.Null, "no object"); + Assert.That(m_view.InsertMediaItem(leaf, null), Is.Null, "no row"); + } + [Test] public void FindExampleSentence_IsOfferedOnARowWithAnObject() { @@ -533,6 +688,23 @@ private DetailField TranslationRow() private DetailField LexemeFormRow() => Rows().Single(f => f.MenuId == "mnuDataTree-LexemeForm"); + // The Caption row of the sense's first picture, as the composer will build it once it + // loads the generated parts (LT-22772). + private DetailField PictureRow() + => new DetailField("Picture/caption@" + m_picture.Hvo, "Caption", "Caption", null, DetailFieldKind.Text, + EditorClassification.Known, null, null, HostRouting.Inherit, null, null, null, + menuId: "mnuDataTree-Picture", objectHvo: m_picture.Hvo); + + // Rows carrying an item id with the sequence's owner as object: the Pronunciations + // section row (the entry) and both rows over a note's examples (the note). + private bool RowIsTheSequencesOwner(DetailField row, string menuId) + => (menuId == "mnuDataTree-Pronunciation" && row.ObjectHvo == m_entry.Hvo) + || menuId == "mnuDataTree-ExtendedNote-Examples"; + + // The item lines of a described menu, separators dropped. + private static List ItemLines(string described) + => described.Split(new[] { Environment.NewLine }, StringSplitOptions.None).Where(l => l != "---").ToList(); + // Every (row, owned id, menu kind) the entry's rows bind: the label menus first, then // the in-string menus, then the hotlinks. private List<(DetailField Row, string MenuId, DetailMenuKind Kind)> OwnedMenuRows() @@ -623,6 +795,26 @@ public bool OnDisplayLaunchGuiControl(object commandObject, ref UIItemDisplayPro Asked = true; return false; } + public bool OnDisplayMoveUpObjectInSequence(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } + public bool OnDisplayMoveDownObjectInSequence(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } + public bool OnDisplayInsertMediaFile(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } + public bool OnDisplayPictureProperties(object commandObject, ref UIItemDisplayProperties display) + { + Asked = true; + return false; + } } private static string Describe(IReadOnlyList items, string indent = "") @@ -651,8 +843,9 @@ private void EnsureAdapter(int targetHvo, string fieldName) method.Invoke(m_view, new object[] { targetHvo, fieldName }); } - // A complex form (Complex Form Type row) with an allomorph, a spelling variant and - // an etymology; its sense has a translated example, an extended note, no subsense. + // A complex form and variant with an allomorph, a spelling variant, two etymologies and + // two pronunciations; its sense has a translated example, two pictures and two notes, + // the first with an example. private void CreateTestEntry() { var stemMorphType = GetMorphTypeOrCreateOne("stem"); @@ -660,8 +853,27 @@ private void CreateTestEntry() m_entry = AddLexeme(m_createdObjects, "command-entry", stemMorphType, "first gloss", noun); m_note = Cache.ServiceLocator.GetInstance().Create(); m_entry.SensesOS[0].ExtendedNoteOS.Add(m_note); + var noteExample = Cache.ServiceLocator.GetInstance().Create(); + m_note.ExamplesOS.Add(noteExample); + noteExample.Example.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("in a note", Cache.DefaultVernWs)); + m_note2 = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS[0].ExtendedNoteOS.Add(m_note2); m_etymology = Cache.ServiceLocator.GetInstance().Create(); m_entry.EtymologyOS.Add(m_etymology); + m_etymology2 = Cache.ServiceLocator.GetInstance().Create(); + m_entry.EtymologyOS.Add(m_etymology2); + m_pronunciation = Cache.ServiceLocator.GetInstance().Create(); + m_entry.PronunciationsOS.Add(m_pronunciation); + m_pronunciation.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("pro", Cache.DefaultVernWs)); + m_entry.PronunciationsOS.Add(Cache.ServiceLocator.GetInstance().Create()); + var pictureFolder = DomainObjectServices.FindOrCreateFolder(Cache, LangProjectTags.kflidPictures, + CmFolderTags.DefaultPictureFolder); + m_picture = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS[0].PicturesOS.Add(m_picture); + m_picture.PictureFileRA = DomainObjectServices.FindOrCreateFile(pictureFolder, "command-picture.jpg"); + var secondPicture = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS[0].PicturesOS.Add(secondPicture); + secondPicture.PictureFileRA = DomainObjectServices.FindOrCreateFile(pictureFolder, "second-picture.jpg"); m_allomorph = Cache.ServiceLocator.GetInstance().Create(); m_entry.AlternateFormsOS.Add(m_allomorph); m_allomorph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("command-allomorph", Cache.DefaultVernWs)); @@ -681,6 +893,13 @@ private void CreateTestEntry() complexFormRef.PrimaryLexemesRS.Add(component); complexFormRef.ComplexEntryTypesRS.Add(Cache.ServiceLocator.GetInstance() .GetObject(LexEntryTypeTags.kguidLexTypCompound)); + var baseEntry = AddLexeme(m_createdObjects, "base", stemMorphType, "base gloss", noun); + m_variantRef = Cache.ServiceLocator.GetInstance().Create(); + m_entry.EntryRefsOS.Add(m_variantRef); + m_variantRef.RefType = LexEntryRefTags.krtVariant; + m_variantRef.ComponentLexemesRS.Add(baseEntry); + m_variantRef.VariantEntryTypesRS.Add(Cache.ServiceLocator.GetInstance() + .GetObject(LexEntryTypeTags.kguidLexTypSpellingVar)); m_variant = AddLexeme(m_createdObjects, "command-variant", stemMorphType, "variant gloss", noun); var variantRef = Cache.ServiceLocator.GetInstance().Create(); diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs index 63291fb58c..ffd437167e 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs @@ -15,6 +15,7 @@ using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; using SIL.LCModel.Core.Text; +using SIL.LCModel.DomainServices; using SIL.LCModel.Infrastructure; using XCore; @@ -49,6 +50,11 @@ public class LexiconMenuExecutionTests : XWorksAppTestBase private ILexEtymology m_etymology1; private ILexEtymology m_etymology2; private ILexEntryRef m_complexFormRef; + private ILexEntryRef m_variantRef1; + private ILexEntryRef m_variantRef2; + private ILexPronunciation m_pronunciation1; + private ILexPronunciation m_pronunciation2; + private ICmPicture m_picture; private RecordEditView m_view; protected override void Init() @@ -150,7 +156,7 @@ public void DeleteComplexFormInfo_RemovesTheEntryRef() InvokeLabelMenuItem(row, "Delete Complex Form Info"); - Assert.That(m_entry.EntryRefsOS, Is.Empty); + Assert.That(m_entry.EntryRefsOS.ToList(), Is.EqualTo(new[] { m_variantRef1, m_variantRef2 }), "the variant refs stay"); Assert.That(m_entry.IsValidObject, Is.True, "only the ref goes, not the entry"); } @@ -488,6 +494,114 @@ public void InsertExample_FromTheExamplesRow_GoesLast() Assert.That(m_sense1.ExamplesOS[0], Is.SameAs(m_example)); } + [Test] + public void MoveEtymologyDown_SwapsItWithTheNext_InOneUndoableStep() + { + InvokeLabelMenuItem(HeaderRowOf(m_etymology1), "Move Etymology Down"); + + Assert.That(m_entry.EtymologyOS.ToList(), Is.EqualTo(new[] { m_etymology2, m_etymology1 })); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo(xWorksStrings.UndoMoveItem)); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_entry.EtymologyOS.ToList(), Is.EqualTo(new[] { m_etymology1, m_etymology2 }), "one Undo restores the order"); + } + + [Test] + public void MovePronunciationUp_FromItsFormRow_SwapsItWithThePrevious() + { + var row = Rows(m_entry).Single(f => f.Field == "Form" && f.ObjectHvo == m_pronunciation2.Hvo); + + InvokeLabelMenuItem(row, "Move Pronunciation Up"); + + Assert.That(m_entry.PronunciationsOS.ToList(), Is.EqualTo(new[] { m_pronunciation2, m_pronunciation1 })); + } + + // The entry's refs are the complex form, then two variants: the third moves to second, + // and from there no further up. + [Test] + public void MoveVariantInfoUp_MovesTheRef_NeverPastTheComplexForm() + { + var row = Rows(m_entry).Single(f => f.MenuId == "mnuDataTree-VariantSpec" && f.ObjectHvo == m_variantRef2.Hvo); + + InvokeLabelMenuItem(row, "Move Variant Info Up"); + + Assert.That(m_entry.EntryRefsOS.ToList(), Is.EqualTo(new[] { m_complexFormRef, m_variantRef2, m_variantRef1 })); + var moved = Rows(m_entry).Single(f => f.MenuId == "mnuDataTree-VariantSpec" && f.ObjectHvo == m_variantRef2.Hvo); + var up = XCoreMenuBridge.ResolveMenu(m_propertyTable.GetValue("window"), "mnuDataTree-VariantSpec") + .Leaves.Single(l => l.CommandId == "CmdDataTree-MoveUp-VariantSpec"); + Assert.That(m_view.MoveItem(up, moved, up: true).IsEnabled, Is.False, "the first slot stays the complex form's"); + } + + [Test] + public void InsertSoundOrMovie_FromAPronunciationRow_LinksTheFilesToThatPronunciation_InOneUndoableStep() + { + m_view.MediaFilesToInsert = () => new[] { "two.mp4", "one.wav" }; + var row = Rows(m_entry).Single(f => f.Field == "Form" && f.ObjectHvo == m_pronunciation2.Hvo); + + InvokeLabelMenuItem(row, "Insert Sound or Movie"); + + Assert.That(m_pronunciation2.MediaFilesOS.Select(m => m.MediaFileRA.InternalPath), Is.EqualTo(new[] { "one.wav", "two.mp4" })); + Assert.That(m_pronunciation1.MediaFilesOS, Is.Empty, "the row's pronunciation takes them, not the entry's first"); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo(xWorksStrings.ksUndoInsertMedia)); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_pronunciation2.MediaFilesOS, Is.Empty, "one Undo removes both links"); + } + + // An entry without a pronunciation gets its first, but only once the user has picked + // files: a cancelled dialog leaves the entry as it was. + [Test] + public void InsertSoundOrMovie_OnAnEntryWithoutPronunciations_CreatesTheFirst_OnlyOnceFilesArePicked() + { + ShowEntry(m_affix); + Assert.That(m_affix.PronunciationsOS, Is.Empty, "precondition"); + m_view.MediaFilesToInsert = () => new string[0]; + + Assert.That(m_view.InsertMediaFiles(LexemeFormRow(m_affix)), Is.Empty); + Assert.That(m_affix.PronunciationsOS, Is.Empty, "cancelled: no pronunciation either"); + + m_view.MediaFilesToInsert = () => new[] { "affix.wav" }; + var created = m_view.InsertMediaFiles(LexemeFormRow(m_affix)); + DrainMediatorAndIdleQueues(); + + Assert.That(m_affix.PronunciationsOS, Has.Count.EqualTo(1)); + Assert.That(m_affix.PronunciationsOS[0].MediaFilesOS.ToList(), Is.EqualTo(created)); + Assert.That(Rows(m_affix).Any(f => f.ObjectHvo == m_affix.PronunciationsOS[0].Hvo), Is.True, + "the recomposed view shows the new pronunciation"); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo(xWorksStrings.ksUndoInsertMedia)); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_affix.PronunciationsOS, Is.Empty, "one Undo removes the pronunciation with its link"); + } + + // The picture row is built: no Avalonia row shows a picture yet (LT-22772). + [Test] + public void PictureProperties_PointsThePictureAtTheFileTheUserPicks_InOneUndoableStep() + { + var leaf = XCoreMenuBridge.ResolveMenu(m_propertyTable.GetValue("window"), "mnuDataTree-Picture") + .Leaves.Single(l => l.CommandId == "CmdDataTree-Properties-Picture"); + var row = new DetailField("Picture/caption@" + m_picture.Hvo, "Caption", "Caption", null, DetailFieldKind.Text, + EditorClassification.Known, null, null, HostRouting.Inherit, null, null, null, + menuId: "mnuDataTree-Picture", objectHvo: m_picture.Hvo); + ICmPicture asked = null; + m_view.PictureFileChosen = picture => + { + asked = picture; + return null; + }; + + m_view.PicturePropertiesItem(leaf, row).Execute(); + + Assert.That(asked, Is.SameAs(m_picture), "the dialog opens on the row's picture"); + Assert.That(m_picture.PictureFileRA.InternalPath, Does.EndWith("command-picture.jpg"), "cancelled: unchanged"); + + m_view.PictureFileChosen = picture => Path.Combine(Path.GetTempPath(), "new-picture.jpg"); + m_view.PicturePropertiesItem(leaf, row).Execute(); + DrainMediatorAndIdleQueues(); + + Assert.That(m_picture.PictureFileRA.InternalPath, Does.EndWith("new-picture.jpg")); + Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo(xWorksStrings.ksUndoUpdatePicture)); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_picture.PictureFileRA.InternalPath, Does.EndWith("command-picture.jpg"), "one Undo restores the file"); + } + // Builds the row's label menu as the host does and runs the named item's own Execute. private void InvokeLabelMenuItem(DetailField row, string label) => Invoke(RecordEditView.ComposeSliceMenuIds(row.MenuId, row.IsMultiStringRow).ToArray(), row, @@ -550,8 +664,9 @@ private DetailField VariantsRow() private DetailField HeaderRowOf(ICmObject obj) => Rows(m_entry).First(f => f.ObjectHvo == obj.Hvo && f.Kind == DetailFieldKind.Header && f.HotlinksId != null); - // A complex-form stem entry with a spelling variant, an allomorph, two etymologies and - // two senses (the first with a translated example and two notes); a suffix; a text. + // A complex form that is also two variants, with an allomorph, two etymologies, two + // pronunciations and two senses, the first with a translated example, two notes and a + // picture; a suffix; a text. private void CreateTestEntry() { var stemMorphType = GetMorphTypeOrCreateOne("stem"); @@ -587,6 +702,28 @@ private void CreateTestEntry() m_complexFormRef.PrimaryLexemesRS.Add(component); m_complexFormRef.ComplexEntryTypesRS.Add(Cache.ServiceLocator.GetInstance() .GetObject(LexEntryTypeTags.kguidLexTypCompound)); + var spellingVariant = Cache.ServiceLocator.GetInstance() + .GetObject(LexEntryTypeTags.kguidLexTypSpellingVar); + m_variantRef1 = Cache.ServiceLocator.GetInstance().Create(); + m_entry.EntryRefsOS.Add(m_variantRef1); + m_variantRef1.RefType = LexEntryRefTags.krtVariant; + m_variantRef1.ComponentLexemesRS.Add(component); + m_variantRef1.VariantEntryTypesRS.Add(spellingVariant); + m_variantRef2 = Cache.ServiceLocator.GetInstance().Create(); + m_entry.EntryRefsOS.Add(m_variantRef2); + m_variantRef2.RefType = LexEntryRefTags.krtVariant; + m_variantRef2.ComponentLexemesRS.Add(component); + m_variantRef2.VariantEntryTypesRS.Add(spellingVariant); + m_pronunciation1 = Cache.ServiceLocator.GetInstance().Create(); + m_entry.PronunciationsOS.Add(m_pronunciation1); + m_pronunciation1.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("pro", Cache.DefaultVernWs)); + m_pronunciation2 = Cache.ServiceLocator.GetInstance().Create(); + m_entry.PronunciationsOS.Add(m_pronunciation2); + m_picture = Cache.ServiceLocator.GetInstance().Create(); + m_sense1.PicturesOS.Add(m_picture); + m_picture.PictureFileRA = DomainObjectServices.FindOrCreateFile( + DomainObjectServices.FindOrCreateFolder(Cache, LangProjectTags.kflidPictures, CmFolderTags.DefaultPictureFolder), + "command-picture.jpg"); m_variant = AddLexeme(m_createdObjects, "command-variant", stemMorphType, "variant gloss", noun); var variantRef = Cache.ServiceLocator.GetInstance().Create();