From 6d63ef36d12b8d138ea0f93507c0eaaa3256d9d8 Mon Sep 17 00:00:00 2001 From: mark-sil <83427558+mark-sil@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:10:37 -0400 Subject: [PATCH] LT-22691: Answer the item menus of the Lexicon detail view natively The Avalonia detail view now answers the menus of an etymology, an extended note, a note's examples, an example, a pronunciation, Variant Info and a picture itself: Move Up and Move Down, Delete, the Inserts, Insert Sound or Movie and Picture Properties all display and run from the row. Until now each of these menus still built the hidden WinForms DataTree to answer them. The WinForms view's Move and Insert Sound or Movie commands now run the same shared rules. Owned by the native authority from this change: * mnuDataTree-Etymology * mnuDataTree-ExtendedNote * mnuDataTree-ExtendedNote-Examples * mnuDataTree-Example * mnuDataTree-Pronunciation * mnuDataTree-VariantSpec * mnuDataTree-Picture, which no Avalonia row reaches until the composer loads the generated parts Co-Authored-By: Claude Opus 5.5 --- Src/FdoUi/DetailRules/DetailSequenceMove.cs | 98 +++++++ Src/FdoUi/DetailRules/MediaFileInsertion.cs | 103 ++++++++ .../DetailRules/DetailSequenceMoveTests.cs | 125 +++++++++ .../DetailRules/MediaFileInsertionTests.cs | 126 +++++++++ .../Hosting/RecordEditView.LexiconMenus.cs | 211 +++++++++++++++- Src/xWorks/DTMenuHandler.cs | 203 +++------------ Src/xWorks/xWorksStrings.Designer.cs | 40 ++- Src/xWorks/xWorksStrings.resx | 12 + .../Hosting/LexiconMenuAuthorityTests.cs | 239 +++++++++++++++++- .../Hosting/LexiconMenuExecutionTests.cs | 143 ++++++++++- 10 files changed, 1120 insertions(+), 180 deletions(-) create mode 100644 Src/FdoUi/DetailRules/DetailSequenceMove.cs create mode 100644 Src/FdoUi/DetailRules/MediaFileInsertion.cs create mode 100644 Src/FdoUi/FdoUiTests/DetailRules/DetailSequenceMoveTests.cs create mode 100644 Src/FdoUi/FdoUiTests/DetailRules/MediaFileInsertionTests.cs 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();