diff --git a/Src/Common/Controls/DetailControls/ConfigureWritingSystemsDlg.cs b/Src/Common/Controls/DetailControls/ConfigureWritingSystemsDlg.cs index fa80ac56c0..a1ba53a70c 100644 --- a/Src/Common/Controls/DetailControls/ConfigureWritingSystemsDlg.cs +++ b/Src/Common/Controls/DetailControls/ConfigureWritingSystemsDlg.cs @@ -10,6 +10,7 @@ using SIL.LCModel.Core.WritingSystems; using SIL.FieldWorks.Common.FwUtils; using XCore; +using SIL.FieldWorks.Common.Framework.DetailControls.Resources; namespace SIL.FieldWorks.Common.Framework.DetailControls { @@ -66,6 +67,10 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + /// The dialog title for the field named . + public static string TitleFor(string fieldLabel) + => string.Format(DetailControlsStrings.ksSliceConfigureWssDlgTitle, fieldLabel); + /// /// Gets the selected writing system. /// diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/MultiStringSliceWritingSystemsTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/MultiStringSliceWritingSystemsTests.cs new file mode 100644 index 0000000000..a69879c253 --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/MultiStringSliceWritingSystemsTests.cs @@ -0,0 +1,62 @@ +// 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 System.Windows.Forms; +using NUnit.Framework; +using SIL.FieldWorks.Common.DetailRules; +using SIL.FieldWorks.Common.Widgets; +using SIL.LCModel; +using SIL.LCModel.DomainServices; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + /// + /// The WinForms Writing Systems menu is answered by the rule the Avalonia detail view also + /// uses. These pin that rule against the view's own writing-system query, so the two detail + /// views cannot offer different writing systems. + /// + [TestFixture] + public class MultiStringSliceWritingSystemsTests : MemoryOnlyBackendProviderTestBase + { + private const int MoFormFormFlid = 5035001; + + [TestCase(WritingSystemServices.kwsAnals, 0)] + [TestCase(WritingSystemServices.kwsVerns, 0)] + [TestCase(WritingSystemServices.kwsAnalVerns, 0)] + // The Pronunciation form, the one shipped part that offers a second set on request. + [TestCase(WritingSystemServices.kwsPronunciations, WritingSystemServices.kwsVerns)] + public void SharedRule_OffersExactlyWhatTheViewOffered(int wsMagic, int optionalMagic) + { + var hvo = Cache.LangProject.Hvo; + using (var host = new Form()) + using (var view = new LabeledMultiStringView(hvo, MoFormFormFlid, wsMagic, optionalMagic, + false, true, true)) + { + host.Controls.Add(view); + view.InnerView.Cache = Cache; + var spec = WritingSystemFieldSpec.FromMagicIds(hvo, wsMagic, optionalMagic, false); + + Assert.That(FieldWritingSystemOptions.Options(Cache, spec).Select(ws => ws.Id), + Is.EqualTo(view.WritingSystemOptions.Select(ws => ws.Id)), + "the menu must offer the writing systems the view offered, in the same order"); + Assert.That(FieldWritingSystemOptions.DefaultShown(Cache, spec).Select(ws => ws.Id), + Is.EqualTo(view.GetWritingSystemOptions(false).Select(ws => ws.Id)), + "and default to the same subset when the field stores no selection"); + } + } + + [Test] + public void SharedRule_OffersMoreThanItShows_WhenTheProjectHasAnUncheckedWritingSystem() + { + var hvo = Cache.LangProject.Hvo; + var spec = WritingSystemFieldSpec.FromMagicIds(hvo, WritingSystemServices.kwsAnals, 0, false); + var offered = FieldWritingSystemOptions.Options(Cache, spec).Select(ws => ws.Id).ToList(); + var shown = FieldWritingSystemOptions.DefaultShown(Cache, spec).Select(ws => ws.Id).ToList(); + + Assert.That(shown, Is.SubsetOf(offered), + "everything shown must be offerable; the menu may offer more than the field shows"); + } + } +} diff --git a/Src/Common/Controls/DetailControls/MultiStringSlice.cs b/Src/Common/Controls/DetailControls/MultiStringSlice.cs index 788ded83b7..027e172974 100644 --- a/Src/Common/Controls/DetailControls/MultiStringSlice.cs +++ b/Src/Common/Controls/DetailControls/MultiStringSlice.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2015-2017 SIL International +// Copyright (c) 2015-2017 SIL International // This software is licensed under the LGPL, version 2.1 or later // (http://www.gnu.org/licenses/lgpl-2.1.html) @@ -10,6 +10,7 @@ using System.Xml; using SIL.LCModel.Core.WritingSystems; using SIL.FieldWorks.Common.Controls; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.Framework.DetailControls.Resources; using SIL.LCModel.Core.KernelInterfaces; using SIL.FieldWorks.Common.FwUtils; @@ -28,8 +29,17 @@ namespace SIL.FieldWorks.Common.Framework.DetailControls /// public class MultiStringSlice : ViewPropertySlice, IWritingSystemChooser { + // The writing-system sets this slice was built for, kept so the Writing Systems menu can + // be answered by the rule both detail views share. + private int m_wsMagic; + private int m_wsMagicOptional; + private bool m_forceIncludeEnglish; + public MultiStringSlice(ICmObject obj, int flid, int ws, int wsOptional, bool forceIncludeEnglish, bool editable, bool spellCheck) { + m_wsMagic = ws; + m_wsMagicOptional = wsOptional; + m_forceIncludeEnglish = forceIncludeEnglish; var view = new LabeledMultiStringView(obj.Hvo, flid, ws, wsOptional, forceIncludeEnglish, editable, spellCheck); Control = view; #if _DEBUG @@ -63,6 +73,9 @@ public override RootSite RootSite /// public void Reuse(ICmObject obj, int flid, int ws, int wsOptional, bool forceIncludeEnglish, bool editable, bool spellCheck) { + m_wsMagic = ws; + m_wsMagicOptional = wsOptional; + m_forceIncludeEnglish = forceIncludeEnglish; var view = (LabeledMultiStringView)Control; Label = null; // new slice normally has this SetupWssToDisplay(); @@ -109,10 +122,19 @@ void HandleRightMouseClickedEvent(SimpleRootSite sender, FwRightMouseClickEventA /// /// public IEnumerable GetVisibleWritingSystems() + => FieldWritingSystemOptions.Shown(m_cache, WritingSystemSpec(), StoredWritingSystems()); + + // The sets this field draws on, as the shared rule takes them. + private WritingSystemFieldSpec WritingSystemSpec() + => WritingSystemFieldSpec.FromMagicIds(Object?.Hvo ?? 0, m_wsMagic, + m_wsMagicOptional, m_forceIncludeEnglish); + + // The selection persisted on this field's part ref, or null when it has none and the + // field shows its default set. + private IReadOnlyList StoredWritingSystems() { - var singlePropertySequenceValue = StringSliceUtils.GetVisibleWSSPropertyValue(PartRef(), - ((LabeledMultiStringView) Control).GetWritingSystemOptions(false)); - return StringSliceUtils.GetVisibleWritingSystems(singlePropertySequenceValue, WritingSystemOptionsForDisplay); + var stored = XmlUtils.GetOptionalAttributeValue(PartRef(), "visibleWritingSystems", null); + return stored == null ? null : ChoiceGroup.DecodeSinglePropertySequenceValue(stored); } public override void Install(DataTree parent) @@ -205,7 +227,7 @@ public bool OnDataTreeWritingSystemsConfigureDlg(object args) using (var dlg = new ConfigureWritingSystemsDlg(WritingSystemOptionsForDisplay, WritingSystemsSelectedForDisplay, m_propertyTable.GetValue("HelpTopicProvider"))) { - dlg.Text = String.Format(DetailControlsStrings.ksSliceConfigureWssDlgTitle, Label); + dlg.Text = ConfigureWritingSystemsDlg.TitleFor(Label); if (dlg.ShowDialog() == DialogResult.OK) PersistAndRedisplayWssToDisplayForPart(dlg.SelectedWritingSystems); } @@ -251,30 +273,18 @@ public bool OnDisplayWritingSystemOptionsForSlice(object parameter, ref UIListDi { CheckDisposed(); display.List.Clear(); - m_propertyTable.SetProperty(display.PropertyName, StringSliceUtils.GetVisibleWSSPropertyValue(PartRef(), - ((LabeledMultiStringView) Control).GetWritingSystemOptions(false)), false); - AddWritingSystemListWithIcuLocales(display, WritingSystemOptionsForDisplay); + // Options, checkmarks and the cannot-empty-the-set rule all come from the shared + // rule, so this menu and the Avalonia one offer the same list. + var options = FieldWritingSystemOptions.Menu(m_cache, WritingSystemSpec(), + StoredWritingSystems()); + m_propertyTable.SetProperty(display.PropertyName, + ChoiceGroup.EncodeSinglePropertySequenceValue( + options.Where(o => o.IsChecked).Select(o => o.Id).ToArray()), false); + foreach (var option in options) + display.List.Add(option.Label, option.Id, null, null, option.CanUncheck); return true;//we handled this, no need to ask anyone else. } - /// - /// stores the list values in terms of icu locale - /// - /// - /// - private void AddWritingSystemListWithIcuLocales(UIListDisplayProperties display, IEnumerable list) - { - var active = StringSliceUtils.GetVisibleWSSPropertyValue(PartRef(), - ((LabeledMultiStringView) Control).GetWritingSystemOptions(false)).Split(','); - foreach (var ws in list) - { - // generally enable all items, but if only one is checked that one is disabled; - // it can't be turned off. - bool enabled = (active.Length != 1 || ws.Id != active[0]); - display.List.Add(ws.DisplayLabel, ws.Id, null, null, enabled); - } - } - /// ------------------------------------------------------------------------------------ /// /// Called when property changed. @@ -320,18 +330,7 @@ private void PersistAndRedisplayWssToDisplayForPart(string singlePropertySequenc /// Get the language project's list of pronunciation writing systems into sync with the supplied list. /// private void UpdatePronunciationWritingSystems(IEnumerable newValues) - { - if (newValues.Count() != m_cache.ServiceLocator.WritingSystems.CurrentPronunciationWritingSystems.Count - || !m_cache.ServiceLocator.WritingSystems.CurrentPronunciationWritingSystems.SequenceEqual(newValues)) - { - NonUndoableUnitOfWorkHelper.Do(m_cache.ServiceLocator.GetInstance(), () => - { - m_cache.ServiceLocator.WritingSystems.CurrentPronunciationWritingSystems.Clear(); - foreach (CoreWritingSystemDefinition ws in newValues) - m_cache.ServiceLocator.WritingSystems.CurrentPronunciationWritingSystems.Add(ws); - }); - } - } + => PronunciationWritingSystems.Sync(m_cache, newValues); /// /// go through all the data tree slices, finding the slices that refer to the same part as this slice diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs index f6a02160aa..d64d064bc1 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -1707,6 +1707,27 @@ public DetailField( /// public bool IsMultiStringRow { get; set; } + /// + /// The layout's optionalWs spec: writing systems this row can be asked to show + /// beyond its set, but never shows by default. Null on every + /// shipped row but the Pronunciation form. Only the Writing Systems menu reads it. + /// + public string OptionalWritingSystem { get; set; } + + /// + /// The layout's forceIncludeEnglish flag: English joins this row's writing-system + /// options even when the project has not checked it. Only the Writing Systems menu + /// reads it. + /// + public bool ForceIncludeEnglish { get; set; } + + /// + /// The row's stored per-writing-system selection (the layout's + /// visibleWritingSystems, as the project override holds it), or null when the row + /// stores none and shows its default set. The Writing Systems menu checks these. + /// + public IReadOnlyList VisibleWritingSystems { get; set; } + /// /// How this row's domain decides two option names are the same name, for rows that mint /// items from typed text. Environments compare with spaces stripped, so the picker must diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionTests.cs index 357351af03..52a0ffbee4 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionTests.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) @@ -49,6 +49,10 @@ public class XmlLayoutImporterTests + + + "; private static ViewDefinitionModel Import(string layoutXml) @@ -195,6 +199,35 @@ public void Import_NoVisibleWritingSystems_LeavesOverrideNull() "a field with no override shows the full configured set (null = no restriction)"); } + [Test] + public void Import_OptionalWsAndForceIncludeEnglish_RideTheNode() + { + // Both expand what the field's Writing Systems menu may OFFER without changing what + // the field shows. Only the Pronunciation form uses optionalWs in the shipped parts. + var model = Import(@" + + +"); + + var field = model.Roots[0]; + Assert.That(field.WritingSystem, Is.EqualTo("all pronunciation")); + Assert.That(field.OptionalWritingSystem, Is.EqualTo("all vernacular")); + Assert.That(field.ForceIncludeEnglish, Is.True); + } + + [Test] + public void Import_WithoutOptionalWs_LeavesBothPropertiesUnset() + { + var model = Import(@" + + +"); + + Assert.That(model.Roots[0].OptionalWritingSystem, Is.Null, + "a part with no optionalWs offers only its own writing systems"); + Assert.That(model.Roots[0].ForceIncludeEnglish, Is.False); + } + [Test] public void Import_DynamicEditor_RaisesInfoDiagnostic() { diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs index 2698e3ea5b..dbf6380723 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs @@ -91,6 +91,13 @@ private static JObject WriteNode(ViewNode node) AddIfPresent(o, "hotlinks", node.HotlinksId); AddIfPresent(o, "helpTopicID", node.HelpTopicId); AddIfPresent(o, "sliceId", node.SliceId); + AddIfPresent(o, "optionalWs", node.OptionalWritingSystem); + if (node.ForceIncludeEnglish) + o["forceIncludeEnglish"] = true; + // The stored selection travels with the two facts above: options without the + // checked set would round-trip a field into the wrong checkmarks. + if (node.VisibleWritingSystems != null && node.VisibleWritingSystems.Count > 0) + o["visibleWritingSystems"] = new JArray(node.VisibleWritingSystems); AddIfPresent(o, "ghost", node.GhostField); AddIfPresent(o, "ghostWs", node.GhostWs); AddIfPresent(o, "ghostClass", node.GhostClass); @@ -216,7 +223,10 @@ private static ViewNode ReadNode(JToken token) condition: ReadCondition((JObject)o["condition"]), chooserLinks: ((JArray)o["chooserLinks"])?.Select(ReadChooserLink).ToList(), helpTopicId: (string)o["helpTopicID"], - sliceId: (string)o["sliceId"]); + sliceId: (string)o["sliceId"], + optionalWritingSystem: (string)o["optionalWs"], + forceIncludeEnglish: (bool?)o["forceIncludeEnglish"] ?? false, + visibleWritingSystems: ((JArray)o["visibleWritingSystems"])?.Select(t => (string)t).ToList()); } private static T ParseEnum(JObject o, string name, T fallback) where T : struct diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs index 920b54228d..207393e3ce 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs @@ -388,8 +388,12 @@ public ViewNode( bool toggleValue = false, bool reorder = false, string helpTopicId = null, - string sliceId = null) + string sliceId = null, + string optionalWritingSystem = null, + bool forceIncludeEnglish = false) { + OptionalWritingSystem = optionalWritingSystem; + ForceIncludeEnglish = forceIncludeEnglish; SliceId = sliceId; HelpTopicId = helpTopicId; Reorder = reorder; @@ -473,6 +477,20 @@ public ViewNode( /// public IReadOnlyList VisibleWritingSystems { get; } + /// + /// The part's optionalWs attribute: writing systems a multistring field can be + /// asked to show beyond its set, but never shows by default. + /// Null when the part has none, which is every shipped part but the Pronunciation form. + /// + public string OptionalWritingSystem { get; } + + /// + /// The part's forceIncludeEnglish attribute: English joins this field's + /// writing-system options even when the project has not checked it. Set on the reversal + /// name and abbreviation parts. + /// + public bool ForceIncludeEnglish { get; } + public IReadOnlyList Children { get; } /// diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs index 54c968eaed..829854b740 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs @@ -311,7 +311,9 @@ private static ViewNode CloneWith(ViewNode n, ViewVisibility visibility, string n.ContextMenuId, n.HotlinksId, n.GhostField, n.GhostWs, n.GhostClass, n.GhostLabel, n.ForVariant, n.CustomEditorClass, n.CustomEditorAssembly, n.GhostInitMethod, n.Condition, n.ChooserLinks, n.EnumStringList, visibleWritingSystems, n.ToggleValue, n.Reorder, - helpTopicId: n.HelpTopicId, sliceId: n.SliceId); + helpTopicId: n.HelpTopicId, sliceId: n.SliceId, + optionalWritingSystem: n.OptionalWritingSystem, + forceIncludeEnglish: n.ForceIncludeEnglish); // Copy a (leaf) node under a new StableId; AutomationId is dropped so the duplicate gets a fresh, // non-colliding identity (the renderer derives one from the new StableId by convention). @@ -323,6 +325,8 @@ private static ViewNode CloneWithId(ViewNode n, string newId) n.ContextMenuId, n.HotlinksId, n.GhostField, n.GhostWs, n.GhostClass, n.GhostLabel, n.ForVariant, n.CustomEditorClass, n.CustomEditorAssembly, n.GhostInitMethod, n.Condition, n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue, n.Reorder, - helpTopicId: n.HelpTopicId, sliceId: n.SliceId); + helpTopicId: n.HelpTopicId, sliceId: n.SliceId, + optionalWritingSystem: n.OptionalWritingSystem, + forceIncludeEnglish: n.ForceIncludeEnglish); } } diff --git a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs index ba3b06c352..5a6877e1cd 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs @@ -36,7 +36,8 @@ public sealed class XmlLayoutImporter : IViewDefinitionImporter { "id", "label", "abbr", "field", "ws", "editor", "visibility", "expansion", "localizationKey", "labelId", "automationId", "routing", "menu", "contextMenu", "hotlinks", - "forVariant", "visibleWritingSystems", "reorder", "helpTopicID" + "forVariant", "visibleWritingSystems", "reorder", "helpTopicID", + "optionalWs", "forceIncludeEnglish" }; public static readonly HashSet HandledObjSeqAttributes = @@ -362,6 +363,11 @@ private ViewNode CreateNode( var visibleWss = ParseWsList(Attr(callerEl, "visibleWritingSystems") ?? Attr(contentEl, "visibleWritingSystems")); + // optionalWs and forceIncludeEnglish widen what the field's Writing Systems + // menu may offer, never what the field shows. + var optionalWs = Attr(contentEl, "optionalWs"); + var forceEnglish = ParseOptionalBool(Attr(contentEl, "forceIncludeEnglish")) ?? false; + // Caller children under a slice-content part (/ wrappers on a section // part, e.g. AsLexemeForm's MorphTypeBasic) become child nodes, mirroring how // DataTree.ProcessPartRefNode realizes them as indented child slices. Other caller @@ -396,7 +402,9 @@ private ViewNode CreateNode( chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null, visibleWritingSystems: visibleWss, sliceId: Attr(contentEl, "id"), - helpTopicId: Attr(contentEl, "helpTopicID")); + helpTopicId: Attr(contentEl, "helpTopicID"), + optionalWritingSystem: optionalWs, + forceIncludeEnglish: forceEnglish); } // Dynamic custom slices keep their legacy class/assembly identity so the host can @@ -421,7 +429,9 @@ private ViewNode CreateNode( // The slice's reorder= attribute: its items may be reordered // even when the property is virtual. reorder: ParseOptionalBool(Attr(contentEl, "reorder")) ?? false, - helpTopicId: Attr(contentEl, "helpTopicID")); + helpTopicId: Attr(contentEl, "helpTopicID"), + optionalWritingSystem: optionalWs, + forceIncludeEnglish: forceEnglish); } case "obj": case "seq": diff --git a/Src/FdoUi/DetailRules/FieldWritingSystemOptions.cs b/Src/FdoUi/DetailRules/FieldWritingSystemOptions.cs new file mode 100644 index 0000000000..2172a9ac0f --- /dev/null +++ b/Src/FdoUi/DetailRules/FieldWritingSystemOptions.cs @@ -0,0 +1,237 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using SIL.LCModel; +using SIL.LCModel.Core.WritingSystems; +using SIL.LCModel.DomainServices; + +// 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 +{ + /// + /// Which writing-system sets a multi-writing-system field draws on: the set it shows, the + /// set it offers on request only, and whether English joins them regardless. Build one + /// with or , whichever the caller holds. + /// + public sealed class WritingSystemFieldSpec + { + private WritingSystemFieldSpec(int objectHvo, int writingSystems, int optionalWritingSystems, + bool forceIncludeEnglish) + { + ObjectHvo = objectHvo; + WritingSystems = writingSystems; + OptionalWritingSystems = optionalWritingSystems; + ForceIncludeEnglish = forceIncludeEnglish; + } + + /// The field's object; some magic sets resolve against it. + public int ObjectHvo { get; } + + /// The magic id of the field's own writing-system set, or 0 for none. + public int WritingSystems { get; } + + /// + /// The magic id of the set the field offers on request only, or 0 for none. The + /// Pronunciation form is the only shipped part that carries one. + /// + public int OptionalWritingSystems { get; } + + /// + /// Whether English joins the options even when the project has not checked it. + /// + public bool ForceIncludeEnglish { get; } + + /// + /// The spec a layout describes: its ws and optionalWs names and its + /// forceIncludeEnglish flag. A name no magic set answers to resolves to none, so + /// the field offers nothing rather than the analysis writing systems the writing-system + /// query falls back to. + /// + public static WritingSystemFieldSpec FromLayout(int objectHvo, string writingSystems, + string optionalWritingSystems, bool forceIncludeEnglish) + => new WritingSystemFieldSpec(objectHvo, + Resolve(WritingSystemServices.GetMagicWsIdFromName(writingSystems)), + Resolve(WritingSystemServices.GetMagicWsIdFromName(optionalWritingSystems)), + forceIncludeEnglish); + + /// The same spec, for a caller that already holds its magic ids. + public static WritingSystemFieldSpec FromMagicIds(int objectHvo, int writingSystems, + int optionalWritingSystems, bool forceIncludeEnglish) + => new WritingSystemFieldSpec(objectHvo, Resolve(writingSystems), + Resolve(optionalWritingSystems), forceIncludeEnglish); + + // Pronunciation sets resolve through the plural id. Seeding the project's list is the + // caller's business: these rules read the model and never write to it. + private static int Resolve(int magicId) + { + switch (magicId) + { + case WritingSystemServices.kwsPronunciation: + case WritingSystemServices.kwsFirstPronunciation: + case WritingSystemServices.kwsPronunciations: + return WritingSystemServices.kwsPronunciations; + } + return magicId; + } + } + + /// + /// One entry of a field's Writing Systems menu: the writing system, whether the field + /// shows it now, and whether the user may switch it off. + /// + public sealed class WritingSystemMenuOption + { + /// The writing system this entry offers. + /// Whether the field shows it now. + /// False for the last shown entry. + public WritingSystemMenuOption(CoreWritingSystemDefinition writingSystem, bool isChecked, + bool canUncheck) + { + WritingSystem = writingSystem ?? throw new ArgumentNullException(nameof(writingSystem)); + IsChecked = isChecked; + CanUncheck = canUncheck; + } + + /// The writing system this entry offers. + public CoreWritingSystemDefinition WritingSystem { get; } + + /// The id the stored selection holds. + public string Id => WritingSystem.Id; + + /// The menu text. + public string Label => WritingSystem.DisplayLabel; + + /// Whether the field shows this writing system now. + public bool IsChecked { get; } + + /// False for the last shown entry, so the set can never be emptied. + public bool CanUncheck { get; } + } + + /// + /// Which writing systems a multi-writing-system field offers, which of them it shows, and + /// which may be switched off. Both detail views answer their Writing Systems menu from + /// here, so neither can drift from the other. + /// + public static class FieldWritingSystemOptions + { + /// + /// Every writing system the field can be told to show: its own set, including ones the + /// project has not checked, then the set it offers on request. + /// + public static IReadOnlyList Options(LcmCache cache, + WritingSystemFieldSpec spec) + { + Require(cache, spec); + // Deduplicated by ID, not by instance: one writing system must never become two + // menu entries, whichever query produced it. + var options = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var ws in ListFor(cache, spec.WritingSystems, spec, includeUnchecked: true)) + { + if (seen.Add(ws.Id)) + options.Add(ws); + } + foreach (var ws in ListFor(cache, spec.OptionalWritingSystems, spec, includeUnchecked: true)) + { + if (seen.Add(ws.Id)) + options.Add(ws); + } + return options; + } + + /// + /// What the field shows when nothing usable is stored: its own set, restricted to the + /// writing systems the project has checked. The set offered on request is absent, since + /// it is never shown unasked. + /// + public static IReadOnlyList DefaultShown(LcmCache cache, + WritingSystemFieldSpec spec) + { + Require(cache, spec); + return ListFor(cache, spec.WritingSystems, spec, includeUnchecked: false); + } + + /// + /// The writing systems the field shows: the stored selection, restricted to the ids + /// that are still options and in the options' order; when + /// none of them is. + /// + /// The field's persisted selection, or null for + /// none. + public static IReadOnlyList Shown(LcmCache cache, + WritingSystemFieldSpec spec, IReadOnlyList storedSelection) + { + var stored = Select(Options(cache, spec), storedSelection); + return stored.Count > 0 ? stored : DefaultShown(cache, spec); + } + + /// + /// The field's Writing Systems menu: every option in order, each marked with whether + /// the field shows it and whether it may be switched off. + /// + /// The field's persisted selection, or null for + /// none. + public static IReadOnlyList Menu(LcmCache cache, + WritingSystemFieldSpec spec, IReadOnlyList storedSelection) + { + // Options once, then the shown set from it: recomputing would query the writing + // systems a second time. + var options = Options(cache, spec); + var stored = Select(options, storedSelection); + var shown = new HashSet( + (stored.Count > 0 ? stored : DefaultShown(cache, spec)).Select(ws => ws.Id), + StringComparer.OrdinalIgnoreCase); + // The last shown writing system cannot be switched off, so a field is never blank. + var last = shown.Count == 1; + return options + .Select(ws => + { + var isChecked = shown.Contains(ws.Id); + return new WritingSystemMenuOption(ws, isChecked, !(isChecked && last)); + }) + .ToList(); + } + + // The options the stored selection names, in OPTION order: a re-checked writing system + // lands at the end of the stored list, and the rows must not reorder on a rebuild. + private static IReadOnlyList Select( + IReadOnlyList options, IReadOnlyList stored) + { + if (stored == null || stored.Count == 0) + return Array.Empty(); + var wanted = new HashSet(stored.Where(id => !string.IsNullOrEmpty(id)), + StringComparer.OrdinalIgnoreCase); + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var ws in options) + { + if (wanted.Contains(ws.Id) && seen.Add(ws.Id)) + result.Add(ws); + } + return result; + } + + private static IReadOnlyList ListFor(LcmCache cache, int magicId, + WritingSystemFieldSpec spec, bool includeUnchecked) + { + if (magicId == 0) + return Array.Empty(); + return WritingSystemServices.GetWritingSystemList(cache, magicId, spec.ObjectHvo, + spec.ForceIncludeEnglish, includeUnchecked); + } + + private static void Require(LcmCache cache, WritingSystemFieldSpec spec) + { + if (cache == null) + throw new ArgumentNullException(nameof(cache)); + if (spec == null) + throw new ArgumentNullException(nameof(spec)); + } + } +} diff --git a/Src/FdoUi/DetailRules/PronunciationWritingSystems.cs b/Src/FdoUi/DetailRules/PronunciationWritingSystems.cs new file mode 100644 index 0000000000..24b8465005 --- /dev/null +++ b/Src/FdoUi/DetailRules/PronunciationWritingSystems.cs @@ -0,0 +1,50 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.Core.WritingSystems; +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 project's current pronunciation writing systems, which a Pronunciation field's + /// writing-system choice also sets (LT-9620). Changing which writing systems that field + /// shows is a data change, not only a view preference, so both detail views must make it. + /// + public static class PronunciationWritingSystems + { + /// + /// Brings the project's current pronunciation writing systems into line with + /// . Does nothing when they already match. The write is not + /// undoable, and joins a unit of work already open rather than failing inside one. + /// + public static void Sync(LcmCache cache, IEnumerable shown) + { + if (cache == null) + throw new ArgumentNullException(nameof(cache)); + if (shown == null) + throw new ArgumentNullException(nameof(shown)); + + var wanted = shown.ToList(); + var current = cache.ServiceLocator.WritingSystems.CurrentPronunciationWritingSystems; + if (wanted.Count == current.Count && current.SequenceEqual(wanted)) + return; + + NonUndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW( + cache.ServiceLocator.GetInstance(), () => + { + current.Clear(); + foreach (var ws in wanted) + current.Add(ws); + }); + } + } +} diff --git a/Src/FdoUi/FdoUiTests/DetailRules/FieldWritingSystemOptionsTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/FieldWritingSystemOptionsTests.cs new file mode 100644 index 0000000000..876a3b11e5 --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/FieldWritingSystemOptionsTests.cs @@ -0,0 +1,204 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.DetailRules; +using SIL.LCModel; +using SIL.LCModel.Core.WritingSystems; + +namespace SIL.FieldWorks.FdoUi +{ + /// + /// The writing systems a multi-writing-system field offers, shows, and lets the user + /// switch off. The distinction that matters is offered-versus-shown: a writing system the + /// project has not checked is offered by the menu but not shown until it is chosen. + /// + [TestFixture] + public class FieldWritingSystemOptionsTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase + { + private const string AllAnalysis = "all analysis"; + private const string AllVernacular = "all vernacular"; + + private WritingSystemFieldSpec Analysis(string optionalWs = null, + bool forceIncludeEnglish = false) + => WritingSystemFieldSpec.FromLayout(0, AllAnalysis, optionalWs, forceIncludeEnglish); + + private static IReadOnlyList Ids(IEnumerable systems) + => systems.Select(ws => ws.Id).ToList(); + + // An analysis writing system the project has NOT checked: active, so the menu offers it, + // but absent from the shown set until the user picks it. + private CoreWritingSystemDefinition AddUncheckedAnalysisWs(string id) + { + // The fixture already runs each test inside a unit of work; a nested one throws. + Cache.ServiceLocator.WritingSystemManager.GetOrSet(id, out var ws); + // Active but NOT current: the project knows it, the user has not checked it. + Cache.ServiceLocator.WritingSystems.AnalysisWritingSystems.Add(ws); + return ws; + } + + [Test] + public void Options_OfferAnUncheckedWritingSystem_ThatDefaultShownDoesNot() + { + var offered = AddUncheckedAnalysisWs("de"); + + var options = Ids(FieldWritingSystemOptions.Options(Cache, Analysis())); + var shown = Ids(FieldWritingSystemOptions.DefaultShown(Cache, Analysis())); + + Assert.That(options, Does.Contain(offered.Id), + "the menu should offer an active writing system the project has not checked"); + Assert.That(shown, Does.Not.Contain(offered.Id), + "but the field should not show it until the user chooses it"); + Assert.That(shown, Is.SubsetOf(options), "everything shown must be offerable"); + } + + [Test] + public void Options_AddTheOptionalSpecsWritingSystems_AfterItsOwn() + { + var withoutOptional = Ids(FieldWritingSystemOptions.Options(Cache, Analysis())); + + var withOptional = Ids(FieldWritingSystemOptions.Options(Cache, Analysis(AllVernacular))); + + Assert.That(withOptional.Take(withoutOptional.Count), Is.EqualTo(withoutOptional), + "the field's own writing systems come first, in their own order"); + Assert.That(withOptional, Is.SupersetOf(withoutOptional)); + Assert.That(withOptional, Does.Contain(Cache.ServiceLocator.WritingSystems.DefaultVernacularWritingSystem.Id), + "the optional spec's writing systems are offered too"); + } + + [Test] + public void Options_OfTheOptionalSpec_AreNotShownByDefault() + { + var vernacular = Cache.ServiceLocator.WritingSystems.DefaultVernacularWritingSystem.Id; + + var shown = Ids(FieldWritingSystemOptions.DefaultShown(Cache, Analysis(AllVernacular))); + + Assert.That(shown, Does.Not.Contain(vernacular), + "the optional spec expands what can be chosen, never what is shown unasked"); + } + + [Test] + public void Options_OfAPartWithoutAnOptionalSpec_AddNothing() + { + // The no-magic-set guard earns its keep here: the query treats an unrecognized + // set as the analysis one, so without it every part lacking optionalWs would + // offer the analysis writing systems. + var vernacularField = WritingSystemFieldSpec.FromLayout(0, AllVernacular, null, false); + var analysisWs = Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem.Id; + + var options = Ids(FieldWritingSystemOptions.Options(Cache, vernacularField)); + + Assert.That(options, Does.Not.Contain(analysisWs), + "a part with no optionalWs should not offer the analysis writing systems"); + Assert.That(options, + Is.EqualTo(Ids(FieldWritingSystemOptions.Options(Cache, + WritingSystemFieldSpec.FromLayout(0, AllVernacular, null, false))))); + } + + [Test] + public void Options_OfASpecNamingNoMagicSet_AreEmpty() + { + // Deliberately unlike the composer's render-side resolver, which falls back to the + // analysis set: a field whose spec names no set has no writing systems to offer. + var spec = WritingSystemFieldSpec.FromLayout(0, "not a magic writing system set", + null, false); + + Assert.That(FieldWritingSystemOptions.Options(Cache, spec), Is.Empty); + Assert.That(FieldWritingSystemOptions.DefaultShown(Cache, spec), Is.Empty); + Assert.That(FieldWritingSystemOptions.Menu(Cache, spec, null), Is.Empty); + } + + [Test] + public void Shown_IsTheStoredSelection_InOptionOrder() + { + var second = AddUncheckedAnalysisWs("de"); + var first = Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem; + + // Stored with the re-checked one appended, as a menu toggle leaves it. + var shown = Ids(FieldWritingSystemOptions.Shown(Cache, Analysis(), + new[] { second.Id, first.Id })); + + Assert.That(shown, Is.EqualTo(new[] { first.Id, second.Id }), + "the field renders its selection in option order, whatever order it was stored in"); + } + + [Test] + public void Shown_DropsAStoredIdThatIsNoLongerAnOption() + { + var real = Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem; + + var shown = Ids(FieldWritingSystemOptions.Shown(Cache, Analysis(), + new[] { "zzz-no-such-ws", real.Id })); + + Assert.That(shown, Is.EqualTo(new[] { real.Id }), "a stale id must not survive"); + } + + [Test] + public void Shown_FallsBackToTheDefault_WhenNothingStoredSurvives() + { + var expected = Ids(FieldWritingSystemOptions.DefaultShown(Cache, Analysis())); + + Assert.That(Ids(FieldWritingSystemOptions.Shown(Cache, Analysis(), null)), + Is.EqualTo(expected), "no stored selection shows the default set"); + Assert.That(Ids(FieldWritingSystemOptions.Shown(Cache, Analysis(), new string[0])), + Is.EqualTo(expected)); + Assert.That(Ids(FieldWritingSystemOptions.Shown(Cache, Analysis(), new[] { "zzz-no-such-ws" })), + Is.EqualTo(expected), "an entirely stale selection must not blank the field"); + } + + [Test] + public void Menu_ChecksWhatIsShown_AndOffersWhatIsNot() + { + var offeredOnly = AddUncheckedAnalysisWs("de"); + var analysis = Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem; + + var menu = FieldWritingSystemOptions.Menu(Cache, Analysis(), new[] { analysis.Id }); + + Assert.That(Ids(menu.Select(m => m.WritingSystem)), + Is.EqualTo(Ids(FieldWritingSystemOptions.Options(Cache, Analysis()))), + "the menu lists every option, in option order"); + Assert.That(menu.Single(m => m.Id == analysis.Id).IsChecked, Is.True); + Assert.That(menu.Single(m => m.Id == offeredOnly.Id).IsChecked, Is.False); + Assert.That(menu.Single(m => m.Id == analysis.Id).Label, + Is.EqualTo(analysis.DisplayLabel), "the menu text is the writing system's label"); + } + + [Test] + public void Menu_CannotUncheckTheLastShownWritingSystem() + { + var second = AddUncheckedAnalysisWs("de"); + var first = Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem; + + var one = FieldWritingSystemOptions.Menu(Cache, Analysis(), new[] { first.Id }); + Assert.That(one.Single(m => m.Id == first.Id).CanUncheck, Is.False, + "the only shown writing system cannot be switched off, so the field is never blank"); + Assert.That(one.Single(m => m.Id == second.Id).CanUncheck, Is.True, + "an unshown one is always available to switch on"); + + var two = FieldWritingSystemOptions.Menu(Cache, Analysis(), new[] { first.Id, second.Id }); + Assert.That(two.Where(m => m.IsChecked).Select(m => m.CanUncheck), Is.All.True, + "with two shown, either may be switched off"); + } + + [Test] + public void EveryEntryPoint_RejectsAMissingCacheOrSpec() + { + Assert.That(() => FieldWritingSystemOptions.Options(null, Analysis()), + Throws.ArgumentNullException); + Assert.That(() => FieldWritingSystemOptions.Options(Cache, null), + Throws.ArgumentNullException); + Assert.That(() => FieldWritingSystemOptions.DefaultShown(null, Analysis()), + Throws.ArgumentNullException); + Assert.That(() => FieldWritingSystemOptions.Shown(Cache, null, null), + Throws.ArgumentNullException); + Assert.That(() => FieldWritingSystemOptions.Menu(null, Analysis(), null), + Throws.ArgumentNullException); + Assert.That(() => new WritingSystemMenuOption(null, false, false), + Throws.ArgumentNullException); + } + } +} diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 0ae621e60a..d28f2c6c26 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Threading; using System.Xml.Linq; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.FwAvalonia; using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; @@ -1188,8 +1189,12 @@ private void WalkTextField(ViewNode node, ICmObject obj, int depth) // A multistring editor is the legacy MultiStringSlice; its in-string context menu adds the // shared mnuDataTree-MultiStringSlice group (Writing Systems submenu), a single-ws string // editor does not. Carry that so the menu composition mirrors the legacy slice test. - textField.IsMultiStringRow = string.Equals(node.RawEditor, - EditorKindMap.MultiStringEditor, StringComparison.OrdinalIgnoreCase); + textField.IsMultiStringRow = IsMultiStringEditor(node); + // The Writing Systems menu offers more than the row renders, so carry the three + // layout facts that decide its option list (FieldWritingSystemOptions). + textField.OptionalWritingSystem = node.OptionalWritingSystem; + textField.ForceIncludeEnglish = node.ForceIncludeEnglish; + textField.VisibleWritingSystems = node.VisibleWritingSystems; if (editable) { // An editable text row over a run-bearing TsString property (String/MultiString) @@ -1211,20 +1216,49 @@ private void WalkTextField(ViewNode node, ICmObject obj, int depth) RegisterTextRowEditHandler(stableId, hvo, flid, type, systems); } + // The multistring editor is the one whose rows carry a Writing Systems menu. + private static bool IsMultiStringEditor(ViewNode node) + => string.Equals(node.RawEditor, EditorKindMap.MultiStringEditor, + StringComparison.OrdinalIgnoreCase); + + // The layout facts that decide a multistring row's writing systems, as the shared + // rule takes them. + private WritingSystemFieldSpec WritingSystemSpecOf(ViewNode node, int hvo) + { + var spec = WritingSystemFieldSpec.FromLayout(hvo, node.WritingSystem, + node.OptionalWritingSystem, node.ForceIncludeEnglish); + // Seeding the project's pronunciation list belongs to the caller: the shared + // rule only reads the model. This keeps what ResolveWritingSystems does. + if (spec.WritingSystems == WritingSystemServices.kwsPronunciations) + WritingSystemServices.InitializePronunciationWritingSystems(_cache); + return spec; + } + // 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) { - IReadOnlyList systems = ResolveWritingSystems(_cache, node.WritingSystem); - // A per-field writing-system visibility override (legacy visibleWritingSystems) restricts - // the resolved set to the authored subset (in the override's order), intersected with the - // 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). - if (_showAllWsFields == null || !_showAllWsFields.Contains(node.StableId)) - systems = ApplyVisibleWritingSystems(systems, node.VisibleWritingSystems); + var revealed = _showAllWsFields != null && _showAllWsFields.Contains(node.StableId); + IReadOnlyList systems; + if (IsMultiStringEditor(node)) + { + // The Writing Systems menu offers writing systems the project has not + // checked. Render from the SAME rule, or a chosen one cannot appear. + var spec = WritingSystemSpecOf(node, hvo); + systems = revealed + ? FieldWritingSystemOptions.Options(_cache, spec) + : FieldWritingSystemOptions.Shown(_cache, spec, node.VisibleWritingSystems); + } + else + { + systems = ResolveWritingSystems(_cache, node.WritingSystem); + // A stored selection restricts the resolved set, in its own order. An empty + // intersection keeps the full set rather than blanking a real field. + if (!revealed) + systems = ApplyVisibleWritingSystems(systems, node.VisibleWritingSystems); + } if ((type == CellarPropertyType.String || type == CellarPropertyType.Unicode) && systems.Count > 0) { diff --git a/Src/xWorks/Avalonia/Hosting/CompositeMenuAuthority.cs b/Src/xWorks/Avalonia/Hosting/CompositeMenuAuthority.cs index bd5ff2ac69..d8246fab2d 100644 --- a/Src/xWorks/Avalonia/Hosting/CompositeMenuAuthority.cs +++ b/Src/xWorks/Avalonia/Hosting/CompositeMenuAuthority.cs @@ -3,6 +3,7 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System; +using System.Collections.Generic; using SIL.FieldWorks.Common.FwAvalonia.Detail; using XCore; @@ -25,12 +26,14 @@ public CompositeMenuAuthority(params IDetailMenuAuthority[] members) public bool Owns(string menuId) => Owner(menuId) != null; public DetailMenuItem Build(string menuId, ChoiceBase leaf) - { - var owner = Owner(menuId); - if (owner == null) - throw new InvalidOperationException(string.Format("No authority owns menu '{0}'.", menuId)); - return owner.Build(menuId, leaf); - } + => Owning(menuId).Build(menuId, leaf); + + public IReadOnlyList BuildList(string menuId, string listId) + => Owning(menuId).BuildList(menuId, listId); + + private IDetailMenuAuthority Owning(string menuId) + => Owner(menuId) ?? throw new InvalidOperationException( + string.Format("No authority owns menu '{0}'.", menuId)); private IDetailMenuAuthority Owner(string menuId) { diff --git a/Src/xWorks/Avalonia/Hosting/IDetailMenuAuthority.cs b/Src/xWorks/Avalonia/Hosting/IDetailMenuAuthority.cs index 219a3b44c1..253c48b6b1 100644 --- a/Src/xWorks/Avalonia/Hosting/IDetailMenuAuthority.cs +++ b/Src/xWorks/Avalonia/Hosting/IDetailMenuAuthority.cs @@ -2,6 +2,7 @@ // 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 SIL.FieldWorks.Common.FwAvalonia.Detail; using XCore; @@ -33,5 +34,16 @@ public interface IDetailMenuAuthority /// authority answers: an owned id must be answered in full, or a leaf would leak as /// visible-but-disabled. DetailMenuItem Build(string menuId, ChoiceBase leaf); + + /// + /// The items of a list-populated submenu under an owned menu id, in display order, each + /// already carrying its label, state and execute action. Empty hides the submenu. + /// + /// The owned menu id the submenu belongs to. + /// The submenu's list attribute: the id xCore would + /// otherwise have asked the mediator to populate. + /// The list is not one this authority + /// answers: an owned id must be answered in full. + IReadOnlyList BuildList(string menuId, string listId); } } diff --git a/Src/xWorks/Avalonia/Hosting/MultiStringMenuAuthority.cs b/Src/xWorks/Avalonia/Hosting/MultiStringMenuAuthority.cs new file mode 100644 index 0000000000..24172201d4 --- /dev/null +++ b/Src/xWorks/Avalonia/Hosting/MultiStringMenuAuthority.cs @@ -0,0 +1,106 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using SIL.FieldWorks.Common.DetailRules; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The native authority for a multi-writing-system row's label menu: the Writing Systems + /// submenu answered from the row and the shared writing-system rule, and the Field + /// Visibility, Move Field and Help leaves delegated to the per-object authority. Owns its + /// id only on a genuine multi-string row; any other row that binds it keeps the mediator. + /// + internal sealed class MultiStringMenuAuthority : IDetailMenuAuthority + { + internal const string MenuId = RecordEditView.MultiStringSliceMenuId; + internal const string WritingSystemListId = "WritingSystemOptionsForSlice"; + internal const string ShowAllCommandId = "CmdDataTree-WritingSystemMenu-ShowAllRightNow"; + internal const string ConfigureCommandId = "CmdDataTree-WritingSystemMenu-Configure"; + + private readonly DetailField _field; + private readonly IDetailMenuAuthority _sharedLeaves; + private readonly Func> _menu; + private readonly Action> _show; + private readonly Func _showAll; + private readonly Func _configure; + + /// Creates the authority for one row's menu. + /// The row. + /// Answers the Field Visibility, Move Field and Help leaves + /// this menu re-declares. + /// The row's writing-system options, checked and enabled. + /// Makes the row show exactly the given writing systems. + /// Builds the Show all right now item from (label, row). + /// Builds the Configure item from (label, row). + public MultiStringMenuAuthority(DetailField field, IDetailMenuAuthority sharedLeaves, + Func> menu, + Action> show, + Func showAll, + Func configure) + { + _field = field ?? throw new ArgumentNullException(nameof(field)); + _sharedLeaves = sharedLeaves ?? throw new ArgumentNullException(nameof(sharedLeaves)); + _menu = menu ?? throw new ArgumentNullException(nameof(menu)); + _show = show ?? throw new ArgumentNullException(nameof(show)); + _showAll = showAll ?? throw new ArgumentNullException(nameof(showAll)); + _configure = configure ?? throw new ArgumentNullException(nameof(configure)); + } + + public bool Owns(string menuId) + => _field.IsMultiStringRow && string.Equals(menuId, MenuId, StringComparison.Ordinal); + + public DetailMenuItem Build(string menuId, ChoiceBase leaf) + { + if (leaf == null) + throw new ArgumentNullException(nameof(leaf)); + var label = XCoreMenuBridge.StripAccelerator(leaf.Label); + switch (leaf.HelpId) + { + case ShowAllCommandId: + return _showAll(label, _field); + case ConfigureCommandId: + return _configure(label, _field); + default: + // Everything else is the per-object menu's leaf set, re-declared here. + return _sharedLeaves.Build(menuId, leaf); + } + } + + public IReadOnlyList BuildList(string menuId, string listId) + { + if (!string.Equals(listId, WritingSystemListId, StringComparison.Ordinal)) + { + throw new InvalidOperationException(string.Format( + "Menu '{0}' has a list submenu '{1}' this authority does not answer.", menuId, listId)); + } + var options = _menu(_field); + return options.Select(option => ToggleItem(option, options)).ToList(); + } + + // A toggle flips its own writing system in or out of the shown set and leaves the rest + // alone. The last shown one is offered disabled, so the set can never be emptied. + private DetailMenuItem ToggleItem(WritingSystemMenuOption option, + IReadOnlyList all) + { + return new DetailMenuItem(option.Label, option.CanUncheck, option.IsChecked, children: null, + execute: option.CanUncheck ? (Action)(() => _show(_field, Toggled(option, all))) : null); + } + + // The shown set with one writing system flipped, computed only when its item is clicked. + private static IReadOnlyList Toggled(WritingSystemMenuOption option, + IReadOnlyList all) + => all + .Where(o => string.Equals(o.Id, option.Id, StringComparison.OrdinalIgnoreCase) + ? !o.IsChecked + : o.IsChecked) + .Select(o => o.Id) + .ToList(); + } +} diff --git a/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs b/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs index 52806d1a9f..420366be2e 100644 --- a/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs +++ b/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs @@ -3,6 +3,7 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System; +using System.Collections.Generic; using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; using XCore; @@ -89,6 +90,12 @@ public DetailMenuItem Build(string menuId, ChoiceBase leaf) } } + // Neither owned id carries a list-populated submenu. + public IReadOnlyList BuildList(string menuId, string listId) + => throw new InvalidOperationException(string.Format( + "Menu '{0}' has a list submenu '{1}' this authority does not answer.", + menuId, listId)); + // Offered on every row; without a located target it is disabled rather than guessed. private DetailMenuItem VisibilityItem(string label, ViewVisibility visibility) => _target.Value == null ? Disabled(label) : _fieldVisibility(label, _target.Value, visibility); diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index ab87bd433b..3e81a4fa74 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -509,6 +509,12 @@ internal void OnDetailWritingSystemFocused(string wsTag) /// internal const string MultiStringSliceMenuId = "mnuDataTree-MultiStringSlice"; + /// + /// The Pronunciation form's menu: a row bound to it also sets the project's current + /// pronunciation writing systems when its shown set changes (LT-9620). + /// + internal const string PronunciationMenuId = "mnuDataTree-Pronunciation"; + /// /// Composes the ordered menu-id list for a row's SLICE menu (label right-click and the /// field-options button): the row's own menu= binding, then exactly ONE shared @@ -687,8 +693,8 @@ private ViewDefinitionOverride ResolveViewOverride(string className, string layo + "'; using the shipped definition.", error)); /// - /// Registers the per-field Field Visibility, Move Field, and writing-system commands that - /// retarget to the project override layer for the Avalonia detail view. Registers nothing + /// Registers the per-field Field Visibility and Move Field commands that retarget to + /// the project override layer for the Avalonia detail view. Registers nothing /// (every command keeps its normal mediator dispatch) when the clicked row carries no /// (class, layout) context, e.g. the first-slice fallback rows, so ordinary dispatch /// stays in force when the override layer cannot be addressed. @@ -701,15 +707,6 @@ internal void AddOverrideCommands(OverrideCommandRegistry registry, DetailField return; } - // Writing-system items dispatch normally; the resulting selection is then copied - // into the override. They need no located template node, so they stay registered - // even when locating fails. - 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(LabelOf(d), field)); - // Unknown/stale target: leave the field commands on mediator dispatch rather than // guess. if (!TryLocateOverrideTarget(field, out var templateId, out var location)) @@ -735,8 +732,25 @@ private static string LabelOf(UIItemDisplayProperties display) /// the hidden command adapter. /// internal IDetailMenuAuthority CreateMenuAuthority(DetailMenuRequest request) - => new CompositeMenuAuthority(CreateReorderVectorAuthority(request), - CreateObjectMenuAuthority(request.Field)); + { + // One per-object authority serves both, so the row's override target is located + // once per menu, not once per authority that asks. + var objectMenu = CreateObjectMenuAuthority(request.Field); + return new CompositeMenuAuthority(CreateReorderVectorAuthority(request), + CreateMultiStringMenuAuthority(request.Field, objectMenu), objectMenu); + } + + /// The native authority for a multi-writing-system row's label menu. + internal IDetailMenuAuthority CreateMultiStringMenuAuthority(DetailField field) + => CreateMultiStringMenuAuthority(field, CreateObjectMenuAuthority(field)); + + private IDetailMenuAuthority CreateMultiStringMenuAuthority(DetailField field, + IDetailMenuAuthority sharedLeaves) + => new MultiStringMenuAuthority(field, sharedLeaves, + menu: WritingSystemMenuOf, + show: ShowWritingSystems, + showAll: ShowAllWritingSystemsItem, + configure: ConfigureWritingSystemsItem); /// The native authority for the row's per-object and Help menus. internal IDetailMenuAuthority CreateObjectMenuAuthority(DetailField field) @@ -882,122 +896,82 @@ private DetailMenuItem ShowAllWritingSystemsItem(string label, DetailField field 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 not one of these: it is the transient reveal - /// (), not a configuration change to persist. - /// - private static bool IsWritingSystemVisibilityChoice(ChoiceBase choice) - { - if (choice is ListPropertyChoice list) - { - return string.Equals(list.ParentProperty, - PropertyConstants.CurrentContextMenuSelectedWsIds, StringComparison.Ordinal); - } + // The row's Writing Systems menu, from the shared rule and the row's own layout facts. + private IReadOnlyList WritingSystemMenuOf(DetailField field) + => FieldWritingSystemOptions.Menu(Cache, WritingSystemSpecOf(field), field.VisibleWritingSystems); - return string.Equals(choice.HelpId, "CmdDataTree-WritingSystemMenu-Configure", - StringComparison.Ordinal); - } + private static WritingSystemFieldSpec WritingSystemSpecOf(DetailField field) + => WritingSystemFieldSpec.FromLayout(field.ObjectHvo, field.WritingSystem, + field.OptionalWritingSystem, field.ForceIncludeEnglish); /// - /// A writing-system item that dispatches normally and then copies the resulting - /// selection into the override: the hidden adapter slice owns the picker and the - /// Configure dialog, while the Avalonia detail view composes from its own override - /// store. + /// Makes the row show exactly the given writing systems: stores the selection in the + /// row's view override, keeps the project's pronunciation writing systems in step on a + /// Pronunciation row, and recomposes. Nothing here reaches the mediator. /// - private DetailMenuItem WritingSystemItem(ChoiceBase choice, UIItemDisplayProperties display, - DetailField field) - { - var isListToggle = choice is ListPropertyChoice; - // The bridge strips execute from disabled items, so the last checked toggle - // (disabled) can never be invoked to EMPTY the set. - return new DetailMenuItem(XCoreMenuBridge.StripAccelerator(display.Text), display.Enabled, - display.Checked, children: null, execute: () => - { - // Snapshot first, so a dialog that changes nothing (e.g. Cancel) copies - // nothing. - var before = isListToggle ? null : CurrentSliceSelectedWritingSystems(); - choice.OnClick(null, EventArgs.Empty); - CopyWritingSystemSelectionToOverride(field, isListToggle, before); - }); - } - - // Copies the click's selection into the row's override and recomposes. A toggle - // updates its property BEFORE the slice: read the property, in option order; - // Configure reads the slice. - private void CopyWritingSystemSelectionToOverride(DetailField field, bool fromListToggle, - IReadOnlyList sliceSetBeforeClick) + private void ShowWritingSystems(DetailField field, IReadOnlyList writingSystems) { try { - var slice = m_dataEntryForm?.CurrentSlice as MultiStringSlice; - if (slice == null) - { - // The command dispatched, but the result is unreadable: say so, or the - // symptom is "the menu did nothing" with no trail. - Logger.WriteEvent("Writing-system selection was not copied: the adapter " - + "slice is unreadable; the view override was not updated."); + // The menu never offers an empty set, so empty means nothing to store -- and an + // empty op would CLEAR the restriction rather than narrow it. + if (writingSystems == null || writingSystems.Count == 0) return; - } - - // A stale adapter target would store another row's set under this row's id. - if (slice.Object == null || slice.Object.Hvo != field.ObjectHvo) - { - Logger.WriteEvent("Writing-system selection was not copied: the adapter " - + "slice is not the clicked row's; the view override was not updated."); - return; - } - - List selected; - if (fromListToggle) - { - var ids = m_propertyTable.GetStringProperty( - PropertyConstants.CurrentContextMenuSelectedWsIds, null); - // Canonicalize: option order, junk tokens dropped -- the stored order is the - // render order. - selected = string.IsNullOrEmpty(ids) - ? null - : StringSliceUtils.GetVisibleWritingSystems(ids, - slice.WritingSystemOptionsForDisplay).Select(ws => ws.Id).ToList(); - } - else - { - selected = slice.WritingSystemsSelectedForDisplay?.Select(ws => ws.Id).ToList(); - if (selected != null && sliceSetBeforeClick != null - && selected.SequenceEqual(sliceSetBeforeClick, StringComparer.Ordinal)) - { - return; // the dialog changed nothing (e.g. Cancel): no override write. - } - } - - // The menu disables the last checked toggle, so an empty set only means "nothing - // to copy" -- and an empty op would CLEAR the restriction, so bail instead. - if (selected == null || selected.Count == 0) - return; - var templateId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId); var op = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibleWritingSystems, - templateId, writingSystems: selected); + templateId, writingSystems: writingSystems.ToList()); 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. + if (string.Equals(field.MenuId, PronunciationMenuId, StringComparison.Ordinal)) + { + // Exactly the chosen writing systems, never a fallback set: a stale id + // must not rewrite the project's list to the defaults. + var chosen = new HashSet(writingSystems, StringComparer.OrdinalIgnoreCase); + var resolved = FieldWritingSystemOptions.Options(Cache, WritingSystemSpecOf(field)) + .Where(ws => chosen.Contains(ws.Id)).ToList(); + if (resolved.Count == chosen.Count) + PronunciationWritingSystems.Sync(Cache, resolved); + } + // A persisted set supersedes any transient reveal on the part. m_showAllWsFields.Remove(templateId); RefreshAvaloniaDetail(); } catch (Exception e) { - Logger.WriteError("Copying the writing-system selection into the view override failed.", e); + Logger.WriteError("Storing the row's writing-system selection failed.", e); } } - // The adapter slice's current selection, or null when it cannot be read. - private IReadOnlyList CurrentSliceSelectedWritingSystems() - => (m_dataEntryForm?.CurrentSlice as MultiStringSlice) - ?.WritingSystemsSelectedForDisplay?.Select(ws => ws.Id).ToList(); + // Opens the per-field Configure dialog on the row's own options and shown set, and + // applies the result. + private DetailMenuItem ConfigureWritingSystemsItem(string label, DetailField field) + => new DetailMenuItem(label, isEnabled: true, isChecked: false, children: null, execute: () => + { + try + { + var spec = WritingSystemSpecOf(field); + var shown = FieldWritingSystemOptions.Shown(Cache, spec, field.VisibleWritingSystems); + using (var dlg = new ConfigureWritingSystemsDlg( + FieldWritingSystemOptions.Options(Cache, spec), shown, + m_propertyTable.GetValue("HelpTopicProvider"))) + { + dlg.Text = ConfigureWritingSystemsDlg.TitleFor(field.Label); + if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) + return; + var chosen = dlg.SelectedWritingSystems.Select(ws => ws.Id).ToList(); + // The dialog answers in option order, the stored set may not: + // same set means nothing changed. + if (new HashSet(chosen, StringComparer.Ordinal) + .SetEquals(shown.Select(ws => ws.Id))) + return; // nothing changed: no override write. + ShowWritingSystems(field, chosen); + } + } + catch (Exception e) + { + Logger.WriteError("Configuring the row's writing systems failed.", e); + } + }); // Writes a SetVisibility op for the field's template id into the project override and recomposes. private void ApplyFieldVisibility(DetailField field, string templateId, ViewVisibility target) diff --git a/Src/xWorks/Avalonia/Hosting/ReferenceItemMenuAuthority.cs b/Src/xWorks/Avalonia/Hosting/ReferenceItemMenuAuthority.cs index 5654dd738f..5522e400fd 100644 --- a/Src/xWorks/Avalonia/Hosting/ReferenceItemMenuAuthority.cs +++ b/Src/xWorks/Avalonia/Hosting/ReferenceItemMenuAuthority.cs @@ -3,6 +3,7 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System; +using System.Collections.Generic; using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.FdoUi; @@ -126,6 +127,12 @@ public DetailMenuItem Build(string menuId, ChoiceBase leaf) } } + // The owned id carries no list-populated submenu. + public IReadOnlyList BuildList(string menuId, string listId) + => throw new InvalidOperationException(string.Format( + "Menu '{0}' has a list submenu '{1}' this authority does not answer.", + menuId, listId)); + // The tool a command's parameters name, or null. private static string ToolOf(CommandChoice command) => command == null ? null : XmlUtils.GetOptionalAttributeValue(command.CommandObject.Parameters[0], "tool"); diff --git a/Src/xWorks/Avalonia/Hosting/ReorderVectorMenuAuthority.cs b/Src/xWorks/Avalonia/Hosting/ReorderVectorMenuAuthority.cs index 77eb516941..6e7917fb3f 100644 --- a/Src/xWorks/Avalonia/Hosting/ReorderVectorMenuAuthority.cs +++ b/Src/xWorks/Avalonia/Hosting/ReorderVectorMenuAuthority.cs @@ -3,6 +3,7 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System; +using System.Collections.Generic; using SIL.FieldWorks.Common.FwAvalonia.Detail; using XCore; @@ -59,6 +60,12 @@ public DetailMenuItem Build(string menuId, ChoiceBase leaf) } } + // Neither owned id carries a list-populated submenu. + public IReadOnlyList BuildList(string menuId, string listId) + => throw new InvalidOperationException(string.Format( + "Menu '{0}' has a list submenu '{1}' this authority does not answer.", + menuId, listId)); + /// /// Whether the row's items may be reordered: an editable reference-vector row whose /// property is a real reference sequence or is marked reorderable. Move Left and Move diff --git a/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs b/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs index 0104400aaf..3baea28557 100644 --- a/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs +++ b/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs @@ -61,9 +61,9 @@ public static IReadOnlyList CreateMenuItems(XWindow window, stri /// owns is populated without any mediator display query and every leaf under it, /// submenus included, is answered by the authority, so nothing on the mediator (the /// hidden DataTree adapter included) takes part in it. Other ids keep the mediator path. + /// A list-populated submenu is answered too: the authority supplies its items, so the + /// group is never populated through the mediator. /// - /// An owned id contains a list-populated - /// submenu, which no authority can answer yet. public static IReadOnlyList CreateMenuItems(XWindow window, string[] menuIds, Func interceptor, IxCoreColleague temporaryColleague, IDetailMenuAuthority authority) @@ -203,18 +203,13 @@ private static List ConvertChildren(ChoiceGroup submenu, } // An owned submenu takes its label from the configuration and its children from the - // authority. Omitted when no child is visible, spliced when inline. A list submenu is - // refused, not left to the mediator. + // authority, a list-populated one included. Omitted when empty, spliced when inline. private static IEnumerable ConvertOwnedSubmenu(ChoiceGroup submenu, IDetailMenuAuthority authority, string ownedId) { - if (!string.IsNullOrEmpty(submenu.ListId)) - { - throw new NotSupportedException(string.Format( - "Menu '{0}' has a list-populated submenu '{1}' that no native authority can answer yet.", - ownedId, submenu.ListId)); - } - var children = ConvertChildren(submenu, null, authority, ownedId); + var children = string.IsNullOrEmpty(submenu.ListId) + ? ConvertChildren(submenu, null, authority, ownedId) + : Normalized(authority.BuildList(ownedId, submenu.ListId)); if (children.Count == 0 || submenu.IsInlineChoiceList) return children; return new[] @@ -223,6 +218,17 @@ private static IEnumerable ConvertOwnedSubmenu(ChoiceGroup subme }; } + // The authority's list items as the renderer needs them: disabled items lose their + // execute action like any other leaf, and stranded separators go. + private static List Normalized(IReadOnlyList items) + { + var result = new List(); + foreach (var item in items ?? Array.Empty()) + result.Add(item.IsSeparator ? item : WithoutExecuteWhenDisabled(item)); + TrimSeparators(result); + return result; + } + // A disabled leaf carries no execute action, so "Execute != null" means invokable for // every consumer -- programmatic invokers included, not just the pointer UI. private static DetailMenuItem WithoutExecuteWhenDisabled(DetailMenuItem item) diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs index 5d471d48e2..674bdbe642 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs @@ -2182,6 +2182,26 @@ public void Compose_CustomMultiStringWithSingularWsSelector_ComposesAPlainString "a plural WsSelector keeps the multistring row and its Writing Systems menu"); } + [Test] + public void Compose_MultiStringRow_CarriesTheLayoutFactsItsWritingSystemMenuNeeds() + { + var fields = Compose(showHidden: true).ToList(); + + // The Pronunciation form is the ONE shipped part with optionalWs: its menu offers the + // vernacular writing systems as well, though the row shows only pronunciation ones. + var pronunciation = fields.First(f => f.Field == "Form" && f.ObjectHvo == m_pronunciation.Hvo); + Assert.That(pronunciation.IsMultiStringRow, Is.True, "precondition: a multistring row"); + Assert.That(pronunciation.WritingSystem, Is.EqualTo("all pronunciation")); + Assert.That(pronunciation.OptionalWritingSystem, Is.EqualTo("all vernacular"), + "the widening spec must reach the row, or its menu offers too few writing systems"); + + var citation = fields.First(f => f.Field == "CitationForm" && f.ObjectHvo == m_entry.Hvo); + Assert.That(citation.IsMultiStringRow, Is.True, "precondition: a multistring row"); + Assert.That(citation.OptionalWritingSystem, Is.Null, + "an ordinary multistring row offers only its own writing systems"); + Assert.That(citation.ForceIncludeEnglish, Is.False); + } + [Test] public void Compose_CustomFields_SitAtTheLegacyPlaceholderPosition() { diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/FieldTypeComposerTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/FieldTypeComposerTests.cs index 037fd6ab18..19886bc7cc 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/FieldTypeComposerTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/FieldTypeComposerTests.cs @@ -84,6 +84,68 @@ public void PerFieldWs_LimitsDisplayedWritingSystems_OneVsMany() Is.SameAs(all), "a stale override that matches nothing keeps the full set, never blanks the field"); } + [Test] + public void PerFieldWs_ShowsAWritingSystemTheProjectHasNotChecked() + { + // The menu offers active-but-unchecked writing systems, so a row must render one. + // Before the shared rule, choosing German alone matched nothing and the row fell + // back to showing them ALL. + CoreWritingSystemDefinition offered = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + Cache.ServiceLocator.WritingSystemManager.GetOrSet("de", out offered); + Cache.ServiceLocator.WritingSystems.VernacularWritingSystems.Add(offered); + }); + + try + { + var form = ComposedFormRow(null, null); + Assume.That(form.Values.Select(v => v.WsTag), Does.Not.Contain(offered.Id), + "precondition: an unchecked writing system is not shown unasked"); + + var restricted = ComposedFormRow(RestrictFormRowTo(form, offered.Id), null); + + Assert.That(restricted.Values.Select(v => v.WsTag), Is.EqualTo(new[] { offered.Id }), + "the row shows exactly the writing system chosen, not every writing system"); + + // Show all right now reveals every OPTION, unchecked ones included. + var templateId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(form.StableId); + var revealed = ComposedFormRow(null, new HashSet { templateId }); + Assert.That(revealed.Values.Select(v => v.WsTag), Does.Contain(offered.Id), + "the reveal shows what the menu offers, not just what the project has checked"); + } + finally + { + // The fixture is shared across tests: an extra writing system would change what + // every later row composes. + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + Cache.ServiceLocator.WritingSystems.VernacularWritingSystems.Remove(offered)); + } + } + + [Test] + public void PerFieldWs_WithNothingStored_ShowsWhatItAlwaysShowed() + { + var expected = DetailComposer.ResolveWritingSystems(Cache, "all vernacular").Select(w => w.Id); + + Assert.That(ComposedFormRow(null, null).Values.Select(v => v.WsTag), Is.EqualTo(expected), + "a row with no stored selection should compose its full configured set"); + } + + // An override restricting the Form row to one writing system. + private static ViewDefinitionOverrideResolver RestrictFormRowTo(DetailField form, string wsTag) + { + var restriction = new ViewDefinitionOverride(form.ClassName, form.LayoutName, "detail", + new[] + { + new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibleWritingSystems, + ViewDefinitionOverrideEditor.StripRuntimeSuffix(form.StableId), + writingSystems: new[] { wsTag }) + }, null); + return (cls, layout) => + cls == form.ClassName && layout == form.LayoutName ? restriction : null; + } + // ---- Transient "Show all right now" reveal ---- // The reveal bypasses the ws restriction for EXACTLY the revealed part: Compose diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs index 461a8b7bb5..c33ecbeaaa 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs @@ -1041,7 +1041,7 @@ public void ReorderVectorAuthority_RejectsALeafItDoesNotAnswer() } [Test] - public void LabelMenu_IsFullyOwned_UnlessItCarriesTheWritingSystemsList() + public void LabelMenu_IsFullyOwned_OnEveryRowKind() { MakeTwoSubentries(); var subentries = SubentriesField(); @@ -1053,8 +1053,18 @@ public void LabelMenu_IsFullyOwned_UnlessItCarriesTheWritingSystemsList() .First(f => f.MenuId == ObjectMenuAuthority.HelpMenuId); Assert.That(XCoreMenuBridge.OwnsAll(authority, LabelMenuIds(helpBound)), Is.True, "a row bound to the empty Help menu merges only owned ids"); - Assert.That(XCoreMenuBridge.OwnsAll(authority, LabelMenuIds(LexemeFormField())), Is.False, - "the multi-string menu's writing-system list still needs the mediator"); + var multiString = OwnedMultiStringField(); + Assert.That(XCoreMenuBridge.OwnsAll(m_view.CreateMenuAuthority(LabelMenuRequest(multiString, NoItem)), + LabelMenuIds(multiString)), Is.True, + "a multi-string row's menu, writing-system list included, is answered natively"); + var lexemeForm = LexemeFormField(); + Assert.That(m_view.CreateMultiStringMenuAuthority(lexemeForm).Owns(MultiStringMenuAuthority.MenuId), + Is.True, "the shared multi-string group is owned even on a row whose own menu id is not yet"); + Assert.That(XCoreMenuBridge.OwnsAll(m_view.CreateMenuAuthority(LabelMenuRequest(lexemeForm, NoItem)), + LabelMenuIds(lexemeForm)), Is.False, + "the Lexeme Form row still needs the adapter for mnuDataTree-LexemeForm, not for its writing systems"); + Assert.That(m_view.CreateMultiStringMenuAuthority(subentries).Owns(MultiStringMenuAuthority.MenuId), + Is.False, "a row that is not multi-string never claims the multi-string menu"); Assert.That(XCoreMenuBridge.OwnsAll(null, new[] { ReorderVectorMenuAuthority.MenuId }), Is.False); } @@ -1098,6 +1108,10 @@ public EchoAuthority(IEnumerable owned, params string[] hidden) public DetailMenuItem Build(string menuId, ChoiceBase leaf) => _hidden.Contains(leaf.HelpId) ? null : new DetailMenuItem(XCoreMenuBridge.StripAccelerator(leaf.Label), isEnabled: true); + + // Echoes the list's id as one item, so a test can see where it spliced in. + public IReadOnlyList BuildList(string menuId, string listId) + => new[] { new DetailMenuItem("list:" + listId, isEnabled: true) }; } // Records whether the mediator asked anyone to display the Always-visible command or a @@ -1202,12 +1216,107 @@ public void TryLocateOverrideTarget_SkipsASequenceItemHeader_AndMapsAGhostRowToI } [Test] - public void OwnedMenu_WithAListSubmenu_IsRefused() + public void OwnedMenu_WithAListSubmenu_TakesItsItemsFromTheAuthority() { + // The writing-system list is the one list-populated submenu under an owned id. + // Populating it through the mediator would need the hidden adapter's slice. var ids = new[] { RecordEditView.MultiStringSliceMenuId }; - Assert.That(() => BuildWithSpy(ids, new EchoAuthority(ids), out _), - Throws.TypeOf().With.Message.Contains("WritingSystemOptionsForSlice"), - "a list-populated submenu has no configured leaves an authority could answer"); + var items = BuildWithSpy(ids, new EchoAuthority(ids), out var askedMediator); + + var writingSystems = FindItem(items, "Writing Systems"); + Assert.That(writingSystems, Is.Not.Null, "the owned submenu survives"); + Assert.That(writingSystems.Children.Select(c => c.Label), + Does.Contain("list:WritingSystemOptionsForSlice"), + "the authority's list items splice in where the list submenu sat"); + Assert.That(askedMediator, Is.False, + "an owned id asks the mediator nothing, the list submenu included"); + } + + // The multi-string menu's native authority: the Writing Systems submenu answered from + // the row and the shared rule, the rest delegated to the per-object authority. + + [Test] + public void MultiStringMenuAuthority_AnswersEveryLeafOfItsMenu() + { + var authority = m_view.CreateMultiStringMenuAuthority(LexemeFormField()); + var window = m_propertyTable.GetValue("window"); + var menu = window.GetContextMenuChoiceGroup(new[] { MultiStringMenuAuthority.MenuId }); + menu.PopulateNow(querySubmenuVisibility: false); + + var leaves = Leaves(menu).ToList(); + Assert.That(leaves.Select(l => l.HelpId), Is.EquivalentTo(new[] + { + MultiStringMenuAuthority.ShowAllCommandId, MultiStringMenuAuthority.ConfigureCommandId, + ObjectMenuAuthority.AlwaysVisibleCommandId, ObjectMenuAuthority.IfDataCommandId, + ObjectMenuAuthority.NormallyHiddenCommandId, ObjectMenuAuthority.MoveFieldUpCommandId, + ObjectMenuAuthority.MoveFieldDownCommandId, ObjectMenuAuthority.HelpCommandId + }), "the shipped menu defines exactly the leaves the authority knows"); + foreach (var leaf in leaves) + { + Assert.That(() => authority.Build(MultiStringMenuAuthority.MenuId, leaf), Throws.Nothing, + "the authority does not answer leaf '{0}'", leaf.HelpId); + } + // The list submenu has no configured leaves; the authority supplies its items. + Assert.That(authority.BuildList(MultiStringMenuAuthority.MenuId, + MultiStringMenuAuthority.WritingSystemListId), Is.Not.Empty, + "the writing-system list offers the row's options"); + } + + [Test] + public void MultiStringMenuAuthority_RejectsWhatItDoesNotAnswer() + { + var authority = m_view.CreateMultiStringMenuAuthority(LexemeFormField()); + var window = m_propertyTable.GetValue("window"); + var reorderMenu = window.GetContextMenuChoiceGroup(new[] { ReorderVectorMenuAuthority.MenuId }); + reorderMenu.PopulateNow(); + var foreignLeaf = reorderMenu.OfType() + .First(c => c.HelpId == ReorderVectorMenuAuthority.AlphabeticalOrderCommandId); + + Assert.That(() => authority.Build(MultiStringMenuAuthority.MenuId, foreignLeaf), + Throws.InvalidOperationException, "a foreign leaf is refused, never left to the mediator"); + Assert.That(() => authority.BuildList(MultiStringMenuAuthority.MenuId, "SomeOtherList"), + Throws.InvalidOperationException, "a foreign list is refused too"); + } + + // A transition contract: it compares the native menu with the mediator path and is + // deleted with that path. The standalone contracts above carry the behaviour after. + [Test] + public void MultiStringMenu_NativeAuthority_RendersWhatTheInterceptorPathRendered() + { + UseHelpProvider(KnowsEveryTopic); + var field = LexemeFormField(); + var ids = LabelMenuIds(field); + EnsureAdapter(field.ObjectHvo, field.Field); + Assume.That(AdapterTargets(field), "precondition: the adapter twin is the Lexeme Form row"); + + var before = Describe(BuildItemsWithOverrideInterceptor(ids, field)); + var after = Describe(BuildAsTheHostDoes(ids, field)); + + Assert.That(after, Is.EqualTo(before), + "the native menu renders what the mediator path rendered, writing systems included"); + } + + [Test] + public void MultiStringMenu_IsBuiltWithoutTheAdapterOrTheMediator() + { + UseHelpProvider(KnowsEveryTopic); + var field = OwnedMultiStringField(); + // No EnsureAdapter: the hidden tree never exists. + + var items = BuildWithSpy(LabelMenuIds(field), + m_view.CreateMenuAuthority(LabelMenuRequest(field, NoItem)), out var asked); + + Assert.That(asked, Is.False, "nothing in a multi-string row's label menu reaches the mediator"); + var writingSystems = FindItem(items, "Writing Systems"); + Assert.That(writingSystems, Is.Not.Null); + var toggles = writingSystems.Children + .Where(c => c.Label != "Show all right now" && c.Label != "Configure...").ToList(); + Assert.That(toggles, Is.Not.Empty, "the row's writing systems are offered"); + Assert.That(toggles.Count(c => c.IsChecked), Is.GreaterThan(0), "the shown ones are checked"); + Assert.That(toggles.Where(c => c.IsEnabled).Select(c => c.Execute), Is.All.Not.Null, + "an enabled toggle executes"); + Assert.That(FindItem(items, "Show all right now")?.Execute, Is.Not.Null); + Assert.That(FindItem(items, "Configure...")?.Execute, Is.Not.Null); } // The per-object menu's native authority: Field Visibility, Move Field and Help answered @@ -1726,6 +1835,9 @@ public void WritingSystemToggle_TwoVernaculars_StoresTheReducedThenRestoredSet() "the stored op holds the set AFTER the uncheck, not the pre-click set"); // Re-check the same writing system on a freshly built menu: the ADD direction. + // The menu reads the row's stored selection, so take the row as the host's + // refresh recomposed it, override applied. + field = LexemeFormFieldWithOverrides(); writingSystems = BuildWritingSystemsSubmenu(field); var reAdd = writingSystems.Children.Single(c => !c.IsSeparator && c.Label == toggled); Assert.That(reAdd.IsChecked, Is.False, "the unchecked toggle stays unchecked"); @@ -1735,8 +1847,8 @@ public void WritingSystemToggle_TwoVernaculars_StoresTheReducedThenRestoredSet() var restored = StoredWritingSystems(field); TestContext.WriteLine("restored: " + string.Join(",", restored)); - // Exact order matters: the property appends the re-checked writing system at the - // end, but the copy canonicalizes to option order so rows never reorder. + // Exact order matters: a toggle stores the set in option order, so rows never + // reorder when a writing system is re-checked. Assert.That(restored, Is.EqualTo(new[] { "fr", "es" }), "re-checking stores the restored set in option order"); } @@ -1906,7 +2018,7 @@ 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, Is.Not.Null, "the reveal item must materialize"); Assert.That(showAll.IsEnabled, Is.True); Assert.That(showAll.Execute, Is.Not.Null); showAll.Execute(); @@ -2076,6 +2188,22 @@ private IReadOnlyList ComposedFormWsTags(HashSet reveal) private DetailField LexemeFormField() => FindLexemeFormRow(DetailComposer.Compose(m_entry, Cache).Model); + // The same row as the host composes it after a refresh: the project override applied. + private DetailField LexemeFormFieldWithOverrides() + => FindLexemeFormRow(DetailComposer.Compose(m_entry, Cache, + overrides: (cls, layout) => GetOverrideStore().TryGet(cls, layout)).Model); + + // A multi-string row bound to no menu of its own, or to the empty Help menu, so every id + // its label menu merges is owned once the multi-string group is. + private DetailField OwnedMultiStringField() + { + var field = DetailComposer.Compose(m_entry, Cache).Model.Fields.FirstOrDefault(f => + f.IsMultiStringRow && f.ObjectHvo != 0 + && (string.IsNullOrEmpty(f.MenuId) || f.MenuId == ObjectMenuAuthority.HelpMenuId)); + Assume.That(field, Is.Not.Null, "the fixture composes a multi-string row with an owned binding"); + return field; + } + // The one locator for the Lexeme Form row (the MoForm's own Form field) in a composed // model. private DetailField FindLexemeFormRow(DetailModel model) @@ -2106,9 +2234,11 @@ private IReadOnlyList BuildItemsWithOverrideInterceptor(string[] private ViewDefinitionOverride ReadOverrideFor(DetailField field) => GetOverrideStore().TryGet(field.ClassName, field.LayoutName); + // The Writing Systems submenu as the host builds it: the multi-string group comes from + // its native authority, whatever the row's own menu id still needs. private DetailMenuItem BuildWritingSystemsSubmenu(DetailField field) { - var items = BuildItemsWithOverrideInterceptor( + var items = BuildAsTheHostDoes( new[] { "mnuDataTree-LexemeForm", "mnuDataTree-MultiStringSlice" }, field); var writingSystems = FindItem(items, "Writing Systems"); Assert.That(writingSystems, Is.Not.Null,