diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 1f94cca718..9824d76322 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -115,8 +115,10 @@ private sealed class CompilerSources public static ComposedDetail Compose(ILexEntry entry, LcmCache cache, bool showHiddenFields = false, SlicePluginRegistry plugins = null, - ViewDefinitionOverrideResolver overrides = null) - => Compose((ICmObject)entry, cache, "Normal", showHiddenFields, plugins, overrides); + ViewDefinitionOverrideResolver overrides = null, + ISet showAllWritingSystemsFields = null) + => Compose((ICmObject)entry, cache, "Normal", showHiddenFields, plugins, overrides, + showAllWritingSystemsFields: showAllWritingSystemsFields); /// /// Compose the structured detail view for ANY record root + starting layout -- the @@ -127,10 +129,14 @@ public static ComposedDetail Compose(ILexEntry entry, LcmCache cache, bool showH /// object and the starting layout instead of hardcoding LexEntry/"Normal", so wiring a new tool onto /// the Avalonia side needs only its registration + (when its layout uses one) a layoutChoiceField. /// + /// 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. public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layoutName = "Normal", bool showHiddenFields = false, SlicePluginRegistry plugins = null, ViewDefinitionOverrideResolver overrides = null, - string layoutChoiceField = null) + string layoutChoiceField = null, + ISet showAllWritingSystemsFields = null) { if (obj == null) throw new ArgumentNullException(nameof(obj)); if (cache == null) throw new ArgumentNullException(nameof(cache)); @@ -150,7 +156,8 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou // bridges the gap (plugin factories run at render time, not compose). IDetailEditContext composedContext = null; var state = new ComposeState(cache, showHiddenFields, - plugins ?? SlicePluginRegistry.Default, () => composedContext, overrides); + plugins ?? SlicePluginRegistry.Default, () => composedContext, overrides, + showAllWritingSystemsFields); state.EnterModel(root); foreach (var node in root.Roots) state.Walk(node, obj, 0); @@ -294,6 +301,9 @@ 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; + // 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; // Per-compose memos -- the morph-type option list is identical for every // IMoForm, and an item layout's menu/hotlinks binding is identical per (class, layout). private List _morphTypeOptions; @@ -327,13 +337,15 @@ public FieldEditHandler HandlerFor(string stableId) public ComposeState(LcmCache cache, bool showHiddenFields, SlicePluginRegistry plugins, Func editContextAccessor, - ViewDefinitionOverrideResolver overrides = null) + ViewDefinitionOverrideResolver overrides = null, + ISet showAllWritingSystemsFields = null) { _cache = cache; _showHidden = showHiddenFields; _plugins = plugins; _editContextAccessor = editContextAccessor; _overrides = overrides; + _showAllWsFields = showAllWritingSystemsFields; _sda = cache.DomainDataByFlid; _mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache; } @@ -1057,9 +1069,9 @@ private void WalkTextField(ViewNode node, ICmObject obj, int depth) RegisterTextRowEditHandler(stableId, hvo, flid, type, systems); } - // The writing systems of a text row: the layout set restricted by the field's per-field - // visibleWritingSystems override, then collapsed to a single derived row ws for a - // single-alternative (String/Unicode) property. Split out of WalkTextField unchanged. + // A text row's writing systems: the layout set, restricted by visibleWritingSystems + // unless the row's part is under the Show-all reveal, then collapsed to one ws for + // String/Unicode props. private IReadOnlyList ResolveTextRowWritingSystems(int hvo, int flid, CellarPropertyType type, ViewNode node) { @@ -1069,7 +1081,8 @@ private IReadOnlyList ResolveTextRowWritingSystems( // field's valid writing systems. An empty intersection keeps the full set rather than hiding // the field entirely (defensive -- a stale override must never blank a real // field). - systems = ApplyVisibleWritingSystems(systems, node.VisibleWritingSystems); + if (_showAllWsFields == null || !_showAllWsFields.Contains(node.StableId)) + systems = ApplyVisibleWritingSystems(systems, node.VisibleWritingSystems); if ((type == CellarPropertyType.String || type == CellarPropertyType.Unicode) && systems.Count > 0) { diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index 9595d33b8c..561b289a7c 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2003-2017 SIL International +// Copyright (c) 2003-2017 SIL International // This software is licensed under the LGPL, version 2.1 or later // (http://www.gnu.org/licenses/lgpl-2.1.html) @@ -73,6 +73,13 @@ public partial class RecordEditView // projects/windows for the app lifetime. private readonly Dictionary m_expansionStates = new Dictionary(); + // The transient "Show all right now" reveal: template StableIds of parts whose rows + // show every writing-system option. Cleared when the shown record changes; never + // persisted. + private readonly HashSet m_showAllWsFields = new HashSet(StringComparer.Ordinal); + // The record m_showAllWsFields belongs to; a different record expires the reveal. + private int m_showAllWsRecordHvo; + private bool ShouldUseAvaloniaLexiconEdit { get { return m_activeUIFramework == UIFramework.Avalonia; } @@ -142,6 +149,10 @@ private void TearDownAvaloniaEntryForm() SettleDetailEdits(); m_detailEditContext.Clear(); m_avaloniaEntryForm?.Dispose(); + // Transient view state dies with the view: rebuilding the host must not + // resurrect a stale reveal. + m_showAllWsFields.Clear(); + m_showAllWsRecordHvo = 0; // Null the host + refresh controller after disposing them. The recreation guards // (EnsureAvaloniaEntryFormInitialized / EnsureAvaloniaRefreshController) key on `== null`, so a // runtime flip New->Legacy->New rebuilds a fresh entry form instead of re-showing a disposed one. @@ -297,6 +308,12 @@ private void ShowAvaloniaEntry(ICmObject obj) // cancel-on-displace remains the safety net. SettleDetailEdits(); + // Record-navigation expiry: the transient reveal belongs to ONE record -- it + // survives focus changes within the record and dies when the record changes. + if (obj == null || obj.Hvo != m_showAllWsRecordHvo) + m_showAllWsFields.Clear(); + m_showAllWsRecordHvo = obj?.Hvo ?? 0; + // Adapter hygiene: the hidden command-routing DataTree must never answer mediator // commands for a PREVIOUS record -- reset it whenever the shown record changes; the // next @@ -333,14 +350,16 @@ private void ShowAvaloniaEntry(ICmObject obj) { composed = lexEntry != null ? DetailComposer.Compose(lexEntry, Cache, showHidden, - overrides: ResolveViewOverride) + overrides: ResolveViewOverride, + showAllWritingSystemsFields: m_showAllWsFields) // 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. : DetailComposer.Compose(obj, Cache, string.IsNullOrEmpty(m_layoutName) ? "Normal" : m_layoutName, showHidden, overrides: ResolveViewOverride, - layoutChoiceField: m_layoutChoiceField); + layoutChoiceField: m_layoutChoiceField, + showAllWritingSystemsFields: m_showAllWsFields); if (composed != null) { detail = composed.Model; @@ -584,6 +603,10 @@ private Func BuildOverrideC // even when locating fails. var registry = new OverrideCommandRegistry(); registry.Add(IsWritingSystemVisibilityChoice, (c, d) => WritingSystemItem(c, d, field)); + // Show all right now never dispatches or persists: it only marks the row for the + // host's transient reveal. + registry.Add("CmdDataTree-WritingSystemMenu-ShowAllRightNow", + (c, d) => ShowAllWritingSystemsItem(d, field)); var templateId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId); // Locate the clicked node in the field's OWN compiled model (with any current override @@ -601,8 +624,8 @@ private Func BuildOverrideC } catch (Exception e) { - Logger.WriteError("Resolving the field's override target failed; the gear-menu field " - + "commands fall back to the legacy path for this row.", e); + Logger.WriteError("Resolving the field's override target failed; this row's " + + "menu-button commands fall back to ordinary command dispatch.", e); return registry.TryBuild; } @@ -647,12 +670,26 @@ private DetailMenuItem MoveItem(UIItemDisplayProperties display, DetailField fie execute: canMove ? (Action)(() => ApplyMoveField(field, location, up)) : null); } + /// + /// The "Show all right now" item: marks the row's part for the transient reveal (every + /// row of the part shows every writing-system option until the user navigates to another + /// record) and recomposes. The reveal is view state, not a command, so the item + /// dispatches nothing and never writes the override. + /// + private DetailMenuItem ShowAllWritingSystemsItem(UIItemDisplayProperties display, DetailField field) + => new DetailMenuItem(XCoreMenuBridge.StripAccelerator(display.Text), isEnabled: true, + isChecked: false, children: null, execute: () => + { + m_showAllWsFields.Add(ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId)); + RefreshAvaloniaDetail(); + }); + /// /// Whether this menu item makes a persistent change to which writing systems a /// multi-writing-system field shows: a per-writing-system toggle (recognized by the /// property its group drives -- the toggles carry no command id) or the Configure - /// dialog. Show all right now is excluded: it is a transient reveal on the slice, - /// not a configuration change, so persisting it would wrongly pin the full set. + /// dialog. Show all right now is not one of these: it is the transient reveal + /// (), not a configuration change to persist. /// private static bool IsWritingSystemVisibilityChoice(ChoiceBase choice) { @@ -742,10 +779,16 @@ private void CopyWritingSystemSelectionToOverride(DetailField field, bool fromLi if (selected == null || selected.Count == 0) return; + var templateId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId); var op = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibleWritingSystems, - ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId), - writingSystems: selected); - MutateOverrideAndRefresh(field, op); + templateId, writingSystems: selected); + if (!TryMutateOverride(field, op)) + return; + + // A successful configuration write replaces any transient reveal on the part: + // a newly persisted display set supersedes the reveal on every row sharing it. + m_showAllWsFields.Remove(templateId); + RefreshAvaloniaDetail(); } catch (Exception e) { @@ -781,22 +824,31 @@ private void ApplyMoveField(DetailField field, ViewNodeLocation location, bool u // Loads-or-creates the (class, layout) override, folds the op in, saves it, and recomposes the // Avalonia detail view so the change is visible immediately. The legacy DataTree/Inventory is untouched. private void MutateOverrideAndRefresh(DetailField field, ViewOverrideOperation op) + { + if (TryMutateOverride(field, op)) + RefreshAvaloniaDetail(); + } + + // Folds the op into the (class, layout) override and saves it, WITHOUT recomposing. + // Returns whether the save succeeded, so a failed save can leave view state untouched. + private bool TryMutateOverride(DetailField field, ViewOverrideOperation op) { try { var store = ViewOverrideStore; if (store == null) - return; + return false; var existing = store.TryGet(field.ClassName, field.LayoutName) ?? new ViewDefinitionOverride(field.ClassName, field.LayoutName, "detail", null, null); var merged = ViewDefinitionOverrideEditor.MergeOperation(existing, op); store.Save(merged); - RefreshAvaloniaDetail(); + return true; } catch (Exception e) { Logger.WriteError("Applying the field override failed.", e); + return false; } } @@ -1056,13 +1108,14 @@ private void PersistLabelColumnWidth(double width) m_propertyTable.SetPropertyPersistence(key, true, PropertyTable.SettingsGroup.LocalSettings); } - // Re-resolves and re-shows the detail view for the current record from current domain state - // (after an external edit or this view's commit/cancel). + // Re-shows the detail view for the current record (external edit, commit/cancel). + // Resolves the shown record like ShowRecord, so a showDescendantInRoot tool recomposes + // the root, not the subrecord. private void RefreshAvaloniaDetail() { if (m_avaloniaEntryForm == null || !ShouldUseAvaloniaLexiconEdit) return; - var current = Clerk?.CurrentObject; + var current = ResolveShownRecord(Clerk?.CurrentObject); if (current == null) return; diff --git a/Src/xWorks/RecordEditView.cs b/Src/xWorks/RecordEditView.cs index 21e0a481ed..1ef6f2fe11 100644 --- a/Src/xWorks/RecordEditView.cs +++ b/Src/xWorks/RecordEditView.cs @@ -360,6 +360,17 @@ protected override void ShowRecord() ShowRecord(new RecordNavigationInfo(Clerk, Clerk.SuppressSaveOnChangeRecord, false, false)); } + // The record the view shows for a clerk object: a showDescendantInRoot tool displays + // the subrecord's owning root, so every show/refresh path resolves through this. + private ICmObject ResolveShownRecord(ICmObject obj) + { + if (obj == null || !m_showDescendantInRoot) + return obj; + while (obj.Owner != Clerk.OwningObject) + obj = obj.Owner; + return obj; + } + /// /// Shows the record on idle. This is where the record is actually shown. /// @@ -421,14 +432,7 @@ bool ShowRecordOnIdle(object parameter) } // Enhance: Maybe do something here to allow changing the templates without the starting the application. - ICmObject obj = Clerk.CurrentObject; - - if (m_showDescendantInRoot) - { - // find the root object of the current object - while (obj.Owner != Clerk.OwningObject) - obj = obj.Owner; - } + ICmObject obj = ResolveShownRecord(Clerk.CurrentObject); if (ShouldUseAvaloniaLexiconEdit && m_avaloniaEntryForm != null) { diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/FieldTypeComposerTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/FieldTypeComposerTests.cs index eaa016275c..037fd6ab18 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/FieldTypeComposerTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/FieldTypeComposerTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2026 SIL International +// 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) @@ -6,6 +6,7 @@ using System.Linq; using NUnit.Framework; using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; using SIL.LCModel; using SIL.LCModel.Core.Cellar; using SIL.LCModel.Core.Text; @@ -83,6 +84,130 @@ public void PerFieldWs_LimitsDisplayedWritingSystems_OneVsMany() Is.SameAs(all), "a stale override that matches nothing keeps the full set, never blanks the field"); } + // ---- Transient "Show all right now" reveal ---- + + // The reveal bypasses the ws restriction for EXACTLY the revealed part: Compose + // consults the template StableId set the host holds until record navigation. + [Test] + public void ShowAllReveal_BypassesTheWsRestriction_ForTheRevealedRowOnly() + { + CoreWritingSystemDefinition second = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + Cache.ServiceLocator.WritingSystemManager.GetOrSet("es", out second); + Cache.ServiceLocator.WritingSystems.AddToCurrentVernacularWritingSystems(second); + }); + try + { + var form = ComposedFormRow(null, null); + var fullSet = form.Values.Select(v => v.WsTag).ToList(); + Assume.That(fullSet.Count, Is.GreaterThanOrEqualTo(2), + "the Form row must offer at least two writing systems for a visible reveal"); + + var templateId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(form.StableId); + var restriction = new ViewDefinitionOverride(form.ClassName, form.LayoutName, "detail", + new[] + { + new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibleWritingSystems, + templateId, writingSystems: new[] { fullSet[0] }) + }, null); + ViewDefinitionOverrideResolver resolver = (cls, layout) => + cls == form.ClassName && layout == form.LayoutName ? restriction : null; + + var restricted = ComposedFormRow(resolver, null); + Assert.That(restricted.Values.Select(v => v.WsTag), Is.EqualTo(new[] { fullSet[0] }), + "precondition: the override restricts the row to one writing system"); + + var revealedElsewhere = ComposedFormRow(resolver, + new HashSet { "not-a-part" }); + Assert.That(revealedElsewhere.Values.Select(v => v.WsTag), + Is.EqualTo(new[] { fullSet[0] }), + "a reveal keyed to another part leaves this row restricted"); + + var revealed = ComposedFormRow(resolver, new HashSet { templateId }); + Assert.That(revealed.Values.Select(v => v.WsTag), Is.EqualTo(fullSet), + "the revealed row composes with its full writing-system set, in layout order"); + } + finally + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + Cache.ServiceLocator.WritingSystems.CurrentVernacularWritingSystems.Remove(second)); + } + } + + // One reveal covers the whole part: every row sharing the template (each sense's + // Gloss) composes with the full set. + [Test] + public void ShowAllReveal_CoversEveryRowOfThePart() + { + CoreWritingSystemDefinition second = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + Cache.ServiceLocator.WritingSystemManager.GetOrSet("es", out second); + Cache.ServiceLocator.WritingSystems.AddToCurrentAnalysisWritingSystems(second); + var senseFactory = Cache.ServiceLocator.GetInstance(); + foreach (var gloss in new[] { "first", "second" }) + { + var sense = senseFactory.Create(); + m_entry.SensesOS.Add(sense); + sense.Gloss.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString(gloss, Cache.DefaultAnalWs)); + } + }); + try + { + var glossRows = GlossRows(null, null); + Assume.That(glossRows.Count, Is.EqualTo(2), "one Gloss row per sense"); + var fullCount = glossRows[0].Values.Count; + Assume.That(fullCount, Is.GreaterThanOrEqualTo(2), + "the Gloss row must offer at least two writing systems for a visible reveal"); + var templateId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(glossRows[0].StableId); + Assume.That(ViewDefinitionOverrideEditor.StripRuntimeSuffix(glossRows[1].StableId), + Is.EqualTo(templateId), "sibling sense rows share one template"); + + var restriction = new ViewDefinitionOverride(glossRows[0].ClassName, + glossRows[0].LayoutName, "detail", new[] + { + new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibleWritingSystems, + templateId, writingSystems: new[] { glossRows[0].Values[0].WsTag }) + }, null); + ViewDefinitionOverrideResolver resolver = (cls, layout) => + cls == glossRows[0].ClassName && layout == glossRows[0].LayoutName + ? restriction + : null; + + Assert.That(GlossRows(resolver, null).Select(r => r.Values.Count), + Is.All.EqualTo(1), "precondition: the restriction reaches both sense rows"); + + Assert.That(GlossRows(resolver, new HashSet { templateId }) + .Select(r => r.Values.Count), Is.All.EqualTo(fullCount), + "one revealed template covers every sense's row"); + } + finally + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + Cache.ServiceLocator.WritingSystems.CurrentAnalysisWritingSystems.Remove(second)); + } + } + + // The composed Lexeme Form row (the MoForm's own Form field), under the given override + // resolver and transient reveal set. + private DetailField ComposedFormRow(ViewDefinitionOverrideResolver overrides, + ISet showAllWritingSystemsFields) + => DetailComposer.Compose(m_entry, Cache, overrides: overrides, + showAllWritingSystemsFields: showAllWritingSystemsFields) + .Model.Fields.Single(f => f.Field == "Form" && f.Kind == DetailFieldKind.Text + && f.ObjectHvo == m_entry.LexemeFormOA.Hvo); + + // Every composed Gloss text row (one per sense), under the given override resolver and + // reveal set. + private List GlossRows(ViewDefinitionOverrideResolver overrides, + ISet showAllWritingSystemsFields) + => DetailComposer.Compose(m_entry, Cache, overrides: overrides, + showAllWritingSystemsFields: showAllWritingSystemsFields) + .Model.Fields.Where(f => f.Field == "Gloss" && f.Kind == DetailFieldKind.Text) + .ToList(); + [Test] public void ShowHidden_RevealsNeverFields_HideOmitsThem() { diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs index 834c867b24..950cde6e20 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs @@ -450,8 +450,7 @@ public void Compose_ResolvesTheOverrideForADescendedLayout() }); TestContext.WriteLine("compose asked for: " + string.Join(", ", asked)); - var row = recomposed.Model.Fields.Single(f => f.Field == "Form" - && f.Kind == DetailFieldKind.Text && f.ObjectHvo == m_entry.LexemeFormOA.Hvo); + var row = FindLexemeFormRow(recomposed.Model); Assert.That(row.Label, Is.EqualTo("OverrideMarker"), "an override keyed by the row's own (class, layout) must reach the composed row"); } @@ -461,6 +460,202 @@ public void Compose_ResolvesTheOverrideForADescendedLayout() } } + // ----------------------------------------------------------------- + // "Show all right now": the transient writing-system reveal + // ----------------------------------------------------------------- + + // The intercepted item must reveal WITHOUT persisting: the row composes with the full + // set while the stored override keeps the restriction. + [Test] + public void ShowAllRightNow_RevealsTheFullSet_WithoutWritingTheOverride() + { + CoreWritingSystemDefinition second = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + Cache.ServiceLocator.WritingSystemManager.GetOrSet("es", out second); + Cache.ServiceLocator.WritingSystems.AddToCurrentVernacularWritingSystems(second); + }); + var field = LexemeFormField(); + try + { + var fullSet = field.Values.Select(v => v.WsTag).ToList(); + Assume.That(fullSet, Is.EqualTo(new List { "fr", "es" }), + "precondition: the unrestricted Lexeme Form row shows both vernaculars"); + GetOverrideStore().Save(new ViewDefinitionOverride(field.ClassName, field.LayoutName, + "detail", new[] + { + new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibleWritingSystems, + ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId), + writingSystems: new[] { "fr" }) + }, null)); + Assert.That(ComposedFormWsTags(RevealedFields()), Is.EqualTo(new[] { "fr" }), + "precondition: the override restricts the composed row"); + + InvokeShowAllRightNow(field); + + Assert.That(RevealedFields(), Does.Contain(TemplateId(field)), + "the click marks the row's part in the host's transient reveal set"); + Assert.That(ComposedFormWsTags(RevealedFields()), Is.EqualTo(fullSet), + "the revealed row composes with the full writing-system set"); + Assert.That(StoredWritingSystems(field), Is.EqualTo(new[] { "fr" }), + "the stored override keeps the restriction -- the reveal is never persisted"); + } + finally + { + DeleteOverrideFor(field); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + Cache.ServiceLocator.WritingSystems.CurrentVernacularWritingSystems.Remove(second)); + } + } + + // The reveal survives everything within the record and expires only when the shown + // record changes. + [Test] + public void TransientReveal_ExpiresOnRecordNavigation() + { + var field = LexemeFormField(); + InvokeShowAllRightNow(field); + Assert.That(RevealedFields(), Does.Contain(TemplateId(field)), "precondition: revealed"); + + ILexEntry other = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var stemMorphType = GetMorphTypeOrCreateOne("stem"); + var noun = GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA); + other = AddLexeme(m_createdObjects, "other-entry", stemMorphType, "other gloss", noun); + }); + m_view.Clerk.JumpToRecord(other.Hvo); + DrainMediatorAndIdleQueues(); + + Assert.That(RevealedFields(), Is.Empty, + "showing a different record expires every transient reveal"); + } + + // A configuration write replaces the reveal: after a toggle, the row shows the newly + // stored set, not the stale full set. + [Test] + public void WritingSystemToggle_ReplacesTheTransientReveal() + { + CoreWritingSystemDefinition second = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + Cache.ServiceLocator.WritingSystemManager.GetOrSet("es", out second); + Cache.ServiceLocator.WritingSystems.AddToCurrentVernacularWritingSystems(second); + }); + var field = LexemeFormField(); + try + { + InvokeShowAllRightNow(field); + Assert.That(RevealedFields(), Does.Contain(TemplateId(field)), "precondition: revealed"); + + var toggle = BuildWritingSystemsSubmenu(field).Children + .First(c => !c.IsSeparator && c.IsChecked && c.Execute != null); + var toggledLabel = toggle.Label; + toggle.Execute(); + DrainMediatorAndIdleQueues(); + + Assert.That(RevealedFields(), Does.Not.Contain(TemplateId(field)), + "persisting a new display set replaces the transient reveal"); + + // Re-check the toggled writing system: the toggle persists the visible set into + // the part-ref Inventory, which is shared across this fixture's tests. + var reAdd = BuildWritingSystemsSubmenu(field).Children + .Single(c => !c.IsSeparator && c.Label == toggledLabel); + reAdd.Execute(); + DrainMediatorAndIdleQueues(); + } + finally + { + DeleteOverrideFor(field); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + Cache.ServiceLocator.WritingSystems.CurrentVernacularWritingSystems.Remove(second)); + } + } + + // Drives the intercepted "Show all right now" item for a row the way + // OnDetailMenuRequested would: target the adapter, materialize the submenu, invoke, + // drain. + private void InvokeShowAllRightNow(DetailField field) + { + EnsureAdapter(field.ObjectHvo, field.Field); + var showAll = FindItem(BuildWritingSystemsSubmenu(field).Children, "Show all right now"); + Assert.That(showAll, Is.Not.Null, "the intercepted reveal item must materialize"); + Assert.That(showAll.IsEnabled, Is.True); + Assert.That(showAll.Execute, Is.Not.Null); + showAll.Execute(); + DrainMediatorAndIdleQueues(); + } + + // The reveal set is keyed by the part's template id, not the runtime row id. + private static string TemplateId(DetailField field) + => ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId); + + // A failed override save must leave the reveal alone: ending it without recomposing + // would collapse the row at the next unrelated refresh, with nothing to explain it. + [Test] + public void TransientReveal_SurvivesAFailedOverrideSave() + { + CoreWritingSystemDefinition second = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + Cache.ServiceLocator.WritingSystemManager.GetOrSet("es", out second); + Cache.ServiceLocator.WritingSystems.AddToCurrentVernacularWritingSystems(second); + }); + var field = LexemeFormField(); + // A file where the store expects its directory: Save's CreateDirectory throws. + var blocker = Path.Combine(Path.GetTempPath(), "fw-reveal-" + Guid.NewGuid().ToString("N")); + File.WriteAllText(blocker, "not a directory"); + var storeField = typeof(RecordEditView).GetField("m_viewOverrideStore", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(storeField, Is.Not.Null, "the override store field must exist"); + var original = storeField.GetValue(m_view); + string toggledLabel = null; + try + { + InvokeShowAllRightNow(field); + Assert.That(RevealedFields(), Does.Contain(TemplateId(field)), "precondition: revealed"); + + storeField.SetValue(m_view, new ViewDefinitionOverrideStore(blocker)); + var toggle = BuildWritingSystemsSubmenu(field).Children + .First(c => !c.IsSeparator && c.IsChecked && c.Execute != null); + toggledLabel = toggle.Label; + toggle.Execute(); + DrainMediatorAndIdleQueues(); + + Assert.That(RevealedFields(), Does.Contain(TemplateId(field)), + "a save that failed must leave the transient reveal in place"); + } + finally + { + storeField.SetValue(m_view, original); + File.Delete(blocker); + // The toggle persists into the part-ref inventory this fixture shares; re-check + // it or later tests see no visible writing system. + if (toggledLabel != null) + { + BuildWritingSystemsSubmenu(field).Children + .Single(c => !c.IsSeparator && c.Label == toggledLabel).Execute(); + DrainMediatorAndIdleQueues(); + } + DeleteOverrideFor(field); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + Cache.ServiceLocator.WritingSystems.CurrentVernacularWritingSystems.Remove(second)); + } + } + + // The host's transient reveal set, read through the same seam the production compose + // uses. + private HashSet RevealedFields() + => (HashSet)GetField(m_view, "m_showAllWsFields"); + + // The Lexeme Form row's composed writing-system tags under the CURRENT stored override + // and the given reveal set -- the same Compose call ShowAvaloniaEntry makes. + private IReadOnlyList ComposedFormWsTags(HashSet reveal) + => FindLexemeFormRow(DetailComposer.Compose(m_entry, Cache, + overrides: (cls, layout) => GetOverrideStore().TryGet(cls, layout), + showAllWritingSystemsFields: reveal).Model) + .Values.Select(v => v.WsTag).ToList(); + // ----------------------------------------------------------------- // Helpers -- production-path command drivers // ----------------------------------------------------------------- @@ -468,8 +663,13 @@ public void Compose_ResolvesTheOverrideForADescendedLayout() // The composed Lexeme Form row, as the production host resolves it before raising a // menu request. private DetailField LexemeFormField() - => DetailComposer.Compose(m_entry, Cache).Model.Fields.Single(f => f.Field == "Form" - && f.Kind == DetailFieldKind.Text && f.ObjectHvo == m_entry.LexemeFormOA.Hvo); + => FindLexemeFormRow(DetailComposer.Compose(m_entry, Cache).Model); + + // The one locator for the Lexeme Form row (the MoForm's own Form field) in a composed + // model. + private DetailField FindLexemeFormRow(DetailModel model) + => model.Fields.Single(f => f.Field == "Form" && f.Kind == DetailFieldKind.Text + && f.ObjectHvo == m_entry.LexemeFormOA.Hvo); private ViewDefinitionOverrideStore GetOverrideStore() {