diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 872e7c8ce4..ffc14bfef4 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -116,9 +116,11 @@ private sealed class CompilerSources public static ComposedDetail Compose(ILexEntry entry, LcmCache cache, bool showHiddenFields = false, SlicePluginRegistry plugins = null, ViewDefinitionOverrideResolver overrides = null, - ISet showAllWritingSystemsFields = null) + ISet showAllWritingSystemsFields = null, + Action writingSystemFocused = null) => Compose((ICmObject)entry, cache, "Normal", showHiddenFields, plugins, overrides, - showAllWritingSystemsFields: showAllWritingSystemsFields); + showAllWritingSystemsFields: showAllWritingSystemsFields, + writingSystemFocused: writingSystemFocused); /// /// Compose the structured detail view for ANY record root + starting layout -- the @@ -132,11 +134,14 @@ public static ComposedDetail Compose(ILexEntry entry, LcmCache cache, bool showH /// Template StableIds of parts under a /// transient "Show all right now" reveal: every row of those parts composes with its /// full writing-system set, ignoring per-field visibility restrictions. + /// The host's editor-focus callback for plugin rows; + /// null when no host supplies one. public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layoutName = "Normal", bool showHiddenFields = false, SlicePluginRegistry plugins = null, ViewDefinitionOverrideResolver overrides = null, string layoutChoiceField = null, - ISet showAllWritingSystemsFields = null) + ISet showAllWritingSystemsFields = null, + Action writingSystemFocused = null) { if (obj == null) throw new ArgumentNullException(nameof(obj)); if (cache == null) throw new ArgumentNullException(nameof(cache)); @@ -157,7 +162,7 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou IDetailEditContext composedContext = null; var state = new ComposeState(cache, showHiddenFields, plugins ?? SlicePluginRegistry.Default, () => composedContext, overrides, - showAllWritingSystemsFields); + showAllWritingSystemsFields, writingSystemFocused); state.EnterModel(root); foreach (var node in root.Roots) state.Walk(node, obj, 0); @@ -301,6 +306,8 @@ public FieldEditHandler HandlerFor(string stableId) // receive (resolved when the factory runs, after Compose has built the context). private readonly SlicePluginRegistry _plugins; private readonly Func _editContextAccessor; + // The host's editor-focus callback, handed to plugin rows through the build context. + private readonly Action _writingSystemFocused; // Parts under the host's transient "Show all right now" reveal (template StableIds); // every row of those parts composes with its full writing-system set. private readonly ISet _showAllWsFields; @@ -338,12 +345,14 @@ public FieldEditHandler HandlerFor(string stableId) public ComposeState(LcmCache cache, bool showHiddenFields, SlicePluginRegistry plugins, Func editContextAccessor, ViewDefinitionOverrideResolver overrides = null, - ISet showAllWritingSystemsFields = null) + ISet showAllWritingSystemsFields = null, + Action writingSystemFocused = null) { _cache = cache; _showHidden = showHiddenFields; _plugins = plugins; _editContextAccessor = editContextAccessor; + _writingSystemFocused = writingSystemFocused; _overrides = overrides; _showAllWsFields = showAllWritingSystemsFields; _sda = cache.DomainDataByFlid; @@ -2704,10 +2713,10 @@ private void AddStructuredText(ViewNode node, ICmObject obj, int depth, int flid private void AddPluginRow(ViewNode node, ICmObject obj, int depth, ISlicePlugin plugin) { // ONE plugin contract -- the build context bundles everything a - // plugin can need (object, node, deferred edit-context accessor, cache); there is - // no - // service-aware marker type test. - var context = new SlicePluginBuildContext(obj, node, _editContextAccessor, _cache); + // plugin can need (object, node, deferred edit-context accessor, cache, focus + // callback); there is no service-aware marker type test. + var context = new SlicePluginBuildContext(obj, node, _editContextAccessor, _cache, + _writingSystemFocused); Func factory = () => plugin.BuildControl(context); AddField(new DetailField(StableId(node, obj), Localize(node.Label) ?? node.Field, node.Field, node.WritingSystem, DetailFieldKind.Custom, node.EditorClassification, diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index 561b289a7c..f9b7351277 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -351,7 +351,8 @@ private void ShowAvaloniaEntry(ICmObject obj) composed = lexEntry != null ? DetailComposer.Compose(lexEntry, Cache, showHidden, overrides: ResolveViewOverride, - showAllWritingSystemsFields: m_showAllWsFields) + showAllWritingSystemsFields: m_showAllWsFields, + writingSystemFocused: OnDetailWritingSystemFocused) // Non-entry roots compose against the tool's configured layout // (m_layoutName, default "Normal"); a type-selected layout (m_layoutChoiceField, e.g. // Notebook RnGenericRec keyed on "Type") resolves to the right variant inside Compose. @@ -359,7 +360,8 @@ private void ShowAvaloniaEntry(ICmObject obj) string.IsNullOrEmpty(m_layoutName) ? "Normal" : m_layoutName, showHidden, overrides: ResolveViewOverride, layoutChoiceField: m_layoutChoiceField, - showAllWritingSystemsFields: m_showAllWsFields); + showAllWritingSystemsFields: m_showAllWsFields, + writingSystemFocused: OnDetailWritingSystemFocused); if (composed != null) { detail = composed.Model; diff --git a/Src/xWorks/Avalonia/Plugins/ReversalIndexEntryPlugin.cs b/Src/xWorks/Avalonia/Plugins/ReversalIndexEntryPlugin.cs index 928ab08599..42a7073dfa 100644 --- a/Src/xWorks/Avalonia/Plugins/ReversalIndexEntryPlugin.cs +++ b/Src/xWorks/Avalonia/Plugins/ReversalIndexEntryPlugin.cs @@ -7,7 +7,6 @@ using System.Linq; using Avalonia.Controls; using SIL.FieldWorks.Common.FwAvalonia.Detail; -using SIL.FieldWorks.Common.FwAvalonia.Seams; using SIL.LCModel; using SIL.LCModel.Core.KernelInterfaces; using SIL.LCModel.Core.Text; @@ -40,6 +39,9 @@ public sealed class ReversalIndexEntryPlugin : ISlicePlugin public const string ReversalIndexEntrySliceClassName = "SIL.FieldWorks.XWorks.LexEd.ReversalIndexEntrySlice"; + /// The editor's automation id when the layout node declares none. + public const string DefaultAutomationId = "ReversalEntriesEditor"; + public string LegacyClassName => ReversalIndexEntrySliceClassName; public Control BuildControl(SlicePluginBuildContext context) @@ -64,7 +66,7 @@ public Control BuildControl(SlicePluginBuildContext context) kind: DetailFieldKind.Text, editorClassification: node?.EditorClassification ?? SIL.FieldWorks.Common.FwAvalonia.ViewDefinition.EditorClassification.Known, - automationId: node?.AutomationId ?? "ReversalEntriesEditor", + automationId: node?.AutomationId ?? DefaultAutomationId, localizationKey: node?.LocalizationKey, routing: node?.Routing ?? SIL.FieldWorks.Common.FwAvalonia.ViewDefinition.HostRouting.Product, values: rows, @@ -74,9 +76,9 @@ public Control BuildControl(SlicePluginBuildContext context) objectHvo: sense.Hvo); var reversalContext = new ReversalDetailEditContext(cache, context.EditContext, entryByWsKey); - var automationId = node?.AutomationId ?? "ReversalEntriesEditor"; + var automationId = node?.AutomationId ?? DefaultAutomationId; return new FwMultiWsTextField(field, automationId, reversalContext, - writingSystemFocused: wsTag => WritingSystemKeyboards.Activate(cache, wsTag)); + writingSystemFocused: context.WritingSystemFocused); } catch (Exception e) { diff --git a/Src/xWorks/Avalonia/Plugins/SlicePlugins.cs b/Src/xWorks/Avalonia/Plugins/SlicePlugins.cs index ac002d4947..84048e40f1 100644 --- a/Src/xWorks/Avalonia/Plugins/SlicePlugins.cs +++ b/Src/xWorks/Avalonia/Plugins/SlicePlugins.cs @@ -41,19 +41,22 @@ public interface ISlicePlugin /// Everything the composer hands a plugin factory, bundled into one contract: /// the row's object and typed node, the detail view's edit context (resolved lazily through the /// composer's deferred accessor -- the context object is created during compose, BEFORE the - /// edit context exists; plugin factories run at render time, after), and the cache. + /// edit context exists; plugin factories run at render time, after), the cache, and the + /// host's writing-system focus callback. /// public sealed class SlicePluginBuildContext { private readonly Func _editContextAccessor; public SlicePluginBuildContext(ICmObject target, ViewNode node, - Func editContextAccessor, LcmCache cache) + Func editContextAccessor, LcmCache cache, + Action writingSystemFocused = null) { Target = target; Node = node; _editContextAccessor = editContextAccessor; Cache = cache; + WritingSystemFocused = writingSystemFocused; } /// The composed row's own object (the slice's object in legacy terms). @@ -66,6 +69,12 @@ public SlicePluginBuildContext(ICmObject target, ViewNode node, public IDetailEditContext EditContext => _editContextAccessor?.Invoke(); public LcmCache Cache { get; } + + /// + /// The host's editor-focus callback, raised with the writing-system tag of the plugin + /// editor that gained focus. Null when the host supplies none. + /// + public Action WritingSystemFocused { get; } } /// diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs index 920649e40b..4ef5a0c7fa 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs @@ -407,6 +407,9 @@ public class DetailComposerTests : MemoryOnlyBackendProviderTestBase public override void TestSetup() { base.TestSetup(); + // The reversal plugin tests build a real Avalonia editor, which needs the (headless) + // platform; run in isolation, no earlier fixture has initialized it. + FwAvaloniaRuntime.EnsureInitialized(); NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => { m_entry = Cache.ServiceLocator.GetInstance().Create(); diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailWritingSystemStateTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailWritingSystemStateTests.cs index 2d3145ed9f..456cc31680 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailWritingSystemStateTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailWritingSystemStateTests.cs @@ -5,8 +5,15 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Xml; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Threading; +using Avalonia.VisualTree; using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; using SIL.LCModel.Infrastructure; @@ -28,6 +35,7 @@ public class DetailWritingSystemStateTests : XWorksAppTestBase { private PropertyTable m_propertyTable; private List m_createdObjects; + private ILexEntry m_entry; private RecordEditView m_view; // The real subscriber under test. MockFwXWindow's bare-minimum LoadUI never loads the // Main.xml listeners, so the fixture creates the one this channel needs itself. @@ -66,6 +74,7 @@ public void TearDownWindow() { NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, DestroyLexiconTestData); m_createdObjects = null; + m_entry = null; m_view = null; // Unsubscribes from the process-wide Pub/Sub singleton; without it a disposed // handler would still hear later fixtures' publishes. @@ -110,6 +119,64 @@ public void EditorFocus_UpdatesWritingSystemHvoProperty_ThroughPubSub() "focusing an analysis editor must move the combo property back"); } + // Plugin rows bypass SliceFactory, so the host's focus handler reaches them only + // through the callback Compose threads into SlicePluginBuildContext. + [Test] + public void PluginRowEditorFocus_UpdatesWritingSystemHvoProperty_ThroughPubSub() + { + var vernacular = Cache.ServiceLocator.WritingSystems.DefaultVernacularWritingSystem; + var analysis = Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem; + var sense = m_entry.SensesOS[0]; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var repository = Cache.ServiceLocator.GetInstance(); + var hadIndex = repository.AllInstances().Any(index => index.WritingSystem == analysis.Id); + var reversalIndex = repository.FindOrCreateIndexForWs(analysis.Handle); + if (!hadIndex) + m_createdObjects.Add(reversalIndex); + var reversalEntry = reversalIndex.FindOrCreateReversalEntry("dwelling"); + reversalEntry.SensesRS.Add(sense); + m_createdObjects.Add(reversalEntry); + }); + + // Compose with the host's handler as the plugin callback, the way ShowAvaloniaEntry + // does, and realize the view with the same handler on the composer-row path. + var composed = DetailComposer.Compose(m_entry, Cache, + writingSystemFocused: m_view.OnDetailWritingSystemFocused); + FwAvaloniaRuntime.EnsureInitialized(); + var view = new DataTree(composed.Model, composed.EditContext, m_view.OnDetailWritingSystemFocused); + var window = new Window { Content = view, Width = 520, Height = 360 }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + // The plugin stamps . on each of its value boxes. + var row = composed.Model.Fields.First(f => f.Kind == DetailFieldKind.Custom + && f.ObjectHvo == sense.Hvo); + var boxId = (row.AutomationId ?? ReversalIndexEntryPlugin.DefaultAutomationId) + "." + analysis.Id; + var reversalBox = view.GetVisualDescendants().OfType() + .FirstOrDefault(box => AutomationProperties.GetAutomationId(box) == boxId); + Assert.That(reversalBox, Is.Not.Null, + "precondition: the Reversal Entries plugin row realized its analysis-ws editor"); + + m_view.OnDetailWritingSystemFocused(vernacular.Id); + Assert.That(CurrentWritingSystemHvoProperty(), + Is.EqualTo(vernacular.Handle.ToString(CultureInfo.InvariantCulture)), + "precondition: the combo property starts away from the reversal row's ws"); + + reversalBox.Focus(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(CurrentWritingSystemHvoProperty(), + Is.EqualTo(analysis.Handle.ToString(CultureInfo.InvariantCulture)), + "focusing a plugin row's editor must publish its ws like every composer-built row"); + } + finally + { + window.Close(); + } + } + private string CurrentWritingSystemHvoProperty() { return m_propertyTable.GetStringProperty(PropertyConstants.WritingSystemHvo, null); @@ -133,17 +200,19 @@ private void CreateLexiconTestData() { var stemMorphType = GetMorphTypeOrCreateOne("stem"); var nounPartOfSpeech = GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA); - AddLexeme(m_createdObjects, "ws-state-entry", stemMorphType, "ws state gloss", nounPartOfSpeech); + m_entry = AddLexeme(m_createdObjects, "ws-state-entry", stemMorphType, "ws state gloss", + nounPartOfSpeech); } private void DestroyLexiconTestData() { if (m_createdObjects == null) return; - foreach (var obj in m_createdObjects) + // Reverse order: a reversal entry is deleted before the index that owns it. + for (var i = m_createdObjects.Count - 1; i >= 0; i--) { - if (obj.IsValidObject && obj is ILexEntry) - obj.Delete(); + if (m_createdObjects[i].IsValidObject) + m_createdObjects[i].Delete(); } } }