diff --git a/Src/Common/Controls/DetailControls/Slice.cs b/Src/Common/Controls/DetailControls/Slice.cs index 6fd9135add..8d850d9a1a 100644 --- a/Src/Common/Controls/DetailControls/Slice.cs +++ b/Src/Common/Controls/DetailControls/Slice.cs @@ -8,12 +8,12 @@ using System.Diagnostics; using System.Drawing; using System.Linq; -using System.Text; using System.Windows.Forms; using System.Xml; using SIL.FieldWorks.Common.Controls; using SIL.FieldWorks.Common.Framework.DetailControls.Resources; using SIL.FieldWorks.Common.FwUtils; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.RootSites; using SIL.FieldWorks.FdoUi; using SIL.FieldWorks.LexText.Controls; @@ -1398,145 +1398,31 @@ public String GetChooserHelpTopicID(string ChooserDlgHelpTopicID) return GetHelpTopicID(ChooserDlgHelpTopicID, "khtpChoose"); } - private String GetHelpTopicID(String xmlHelpTopicID, String generatedIDPrefix) - { - String helpTopicID; - - if (xmlHelpTopicID == "khtpField-PhRegularRule-RuleFormula") - xmlHelpTopicID = "khtpChoose-Environment"; - - if (!String.IsNullOrEmpty(xmlHelpTopicID)) - helpTopicID = xmlHelpTopicID; - else - { - helpTopicID = GenerateHelpTopicId(generatedIDPrefix); - } - return helpTopicID; - } + // An authored id returns at once; the subject is described only when generating. + private string GetHelpTopicID(string xmlHelpTopicID, string generatedIDPrefix) + => FieldHelpTopics.Resolve(xmlHelpTopicID, generatedIDPrefix, DescribeHelpTopicSubject, helpTopicIsValid); - private string GenerateHelpTopicId(string helpTopicPrefix) + private HelpTopicSubject DescribeHelpTopicSubject() { - String generatedHelpTopicID; - - String tempfieldName = XmlUtils.GetOptionalAttributeValue(ConfigurationNode, "field"); - String templabelName = XmlUtils.GetOptionalAttributeValue(ConfigurationNode, "label"); - String areaName = m_propertyTable.GetStringProperty("areaChoice", null); - string toolName = m_propertyTable.GetStringProperty("currentContentControl", null); - int parentHvo = Convert.ToInt32(XmlUtils.GetOptionalAttributeValue(ConfigurationNode, "hvoDisplayParent")); - - if (tempfieldName == "Targets" && parentHvo != 0) - // Ceoss Reference (entry level) or lexical relation (sense level) subitems + var fieldName = XmlUtils.GetOptionalAttributeValue(ConfigurationNode, "field"); + var subject = new HelpTopicSubject { - var repo = m_cache.ServiceLocator.GetInstance(); - ILexEntry lex; - repo.TryGetObject(parentHvo, out lex); - - if (lex != null) // It must be the entry level - { - generatedHelpTopicID = helpTopicPrefix + "-" + toolName + "-CrossReferenceSubitem"; - } - else // It must be the sense level - { - generatedHelpTopicID = helpTopicPrefix + "-" + toolName + "-LexicalRelationSubitem"; - - } - } - else + FieldName = fieldName, + Label = XmlUtils.GetOptionalAttributeValue(ConfigurationNode, "label"), + ClassName = Cache.DomainDataByFlid.MetaDataCache.GetClassName(Object.ClassID), + OwnerClassName = Object.Owner?.ClassName, + SortKey = Object is ICmPossibility ? Object.SortKey : null, + AreaName = m_propertyTable.GetStringProperty("areaChoice", null), + ToolName = m_propertyTable.GetStringProperty("currentContentControl", null) + }; + // A lexical-relation Targets slice carries the entry or sense it is shown under. + var parentHvo = Convert.ToInt32(XmlUtils.GetOptionalAttributeValue(ConfigurationNode, "hvoDisplayParent")); + if (fieldName == "Targets" && parentHvo != 0) { - templabelName = getAlphaNumeric(templabelName); - if (String.IsNullOrEmpty(tempfieldName)) - { - // try to use the slice label, without spaces. - tempfieldName = templabelName; - } - generatedHelpTopicID = GetGeneratedHelpTopicId(helpTopicPrefix, tempfieldName); - if (!helpTopicIsValid(generatedHelpTopicID)) - { - // try to use the slice label, without spaces if the helpTopicID does not work for the field xml attribute. - generatedHelpTopicID = GetGeneratedHelpTopicId(helpTopicPrefix, templabelName); - if (!helpTopicIsValid(generatedHelpTopicID)) - { - if (helpTopicPrefix.Equals("khtpChoose")) - generatedHelpTopicID = "khtpChoose-CmPossibility"; - else if (areaName == "lists") - { - generatedHelpTopicID = "khtp-CustomListField"; // If the list isn't defined, use the generic list help topic - - } - else - { - generatedHelpTopicID = "khtpNoHelpTopic"; // else use the generic no help topic - } - } - } - } - - return generatedHelpTopicID; - } - - private string GetGeneratedHelpTopicId(string helpTopicPrefix, String fieldName) - { - var ownerClassName = Object.Owner == null ? null : Object.Owner.ClassName; - var className = Cache.DomainDataByFlid.MetaDataCache.GetClassName(Object.ClassID); - // Distinguish the Example (sense) field and the expanded example (LexExtendedNote) field - className = (fieldName == "Example" && ownerClassName == "LexExtendedNote") ? "LexExtendedNote" : className; - // Distinguish the Translation (sense) field and the expanded example (LexExtendedNote) field - className = fieldName.StartsWith("Translation")&& (ownerClassName == "LexExtendedNote" || (Object.Owner != null && Object.Owner.ClassName == "LexExtendedNote")) ? "LexExtendedNote" : className; - var toolName = m_propertyTable.GetStringProperty("currentContentControl", null); - - String generatedHelpTopicID; - - generatedHelpTopicID = helpTopicPrefix + "-" + toolName + "-" + className + "-" + fieldName; - - if (!helpTopicIsValid(generatedHelpTopicID)) - { - if (String.Equals(className, "CmPossibility")) - generatedHelpTopicID = helpTopicPrefix + "-" + toolName + "-" + Object.SortKey + "-" + fieldName; - - if (!helpTopicIsValid(generatedHelpTopicID)) - { - generatedHelpTopicID = helpTopicPrefix + "-" + toolName + "-" + fieldName; - if (!helpTopicIsValid(generatedHelpTopicID)) - { - generatedHelpTopicID = helpTopicPrefix + "-" + className + "-" + fieldName; - if (!helpTopicIsValid(generatedHelpTopicID)) - { - generatedHelpTopicID = helpTopicPrefix + "-" + fieldName; - } - } - } - } - return generatedHelpTopicID; - } - - /// - /// Generates a possible help topic id from the field name, but does NOT check it for validity! - /// - private static string getAlphaNumeric(string fromStr) - { - var candidateID = new StringBuilder(""); - - if (String.IsNullOrEmpty(fromStr)) - return candidateID.ToString(); - - // Should we capitalize the next letter? - bool nextCapital = true; - - // Lets turn our field into a candidate help page! - foreach (char ch in fromStr) - { - if (Char.IsLetterOrDigit(ch)) // might we include numbers someday? - { - if (nextCapital) - candidateID.Append(Char.ToUpper(ch)); - else - candidateID.Append(ch); - nextCapital = false; - } - else // unrecognized character... exclude it - nextCapital = true; // next letter should be a capital + subject.TargetsParentIsEntry = m_cache.ServiceLocator.GetInstance() + .TryGetObject(parentHvo, out _); } - return candidateID.ToString(); + return subject; } /// @@ -1546,9 +1432,7 @@ private bool helpTopicIsValid(String helpStr) { if (m_mediator == null) return false; - var helpTopicProvider = m_propertyTable.GetValue("HelpTopicProvider"); - return (helpTopicProvider != null && !String.IsNullOrEmpty(helpStr)) - && (helpTopicProvider.GetHelpString(helpStr) != null); + return FieldHelpTopics.KnownBy(m_propertyTable.GetValue("HelpTopicProvider"))(helpStr); } /// diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs index 2ad4fb4427..6455f79a6d 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -1503,6 +1503,40 @@ public DetailLinkRequest(DetailField field, DetailChooserLink link) public DetailChooserLink Link { get; } } + /// + /// The inputs for generating a row's help topic when the row has no + /// . Plain strings only, so this layer stays free of + /// the domain model. + /// + public sealed class DetailHelpTopicSource + { + /// Captures the generator's inputs for one row. + /// The layout field name; may be null. + /// The raw, unlocalized layout label; may be null. + /// The class of the row's object. + /// The owner's class; null when unowned. + /// The object's sort key. + /// For a lexical-relation row: true under an entry, + /// false under a sense; null for other rows. + public DetailHelpTopicSource(string fieldName, string label, string className, + string ownerClassName, string sortKey, bool? targetsParentIsEntry = null) + { + FieldName = fieldName; + Label = label; + ClassName = className; + OwnerClassName = ownerClassName; + SortKey = sortKey; + TargetsParentIsEntry = targetsParentIsEntry; + } + + public string FieldName { get; } + public string Label { get; } + public string ClassName { get; } + public string OwnerClassName { get; } + public string SortKey { get; } + public bool? TargetsParentIsEntry { get; } + } + /// /// A field on a lexical-edit detail view, projected from a typed and bound to live /// values by an . This is the product contract that replaces the @@ -1610,6 +1644,18 @@ public DetailField( /// public bool CanResetItemOrder { get; } + /// + /// The layout's authored help topic id, or null when the host generates one from + /// . Settable so the composer can stamp it from the node. + /// + public string HelpTopicId { get; set; } + + /// + /// What the host needs to generate this row's help topic when the layout authors + /// none; null on rows that have no object. Stamped by the composer. + /// + public DetailHelpTopicSource HelpTopicSource { get; set; } + /// False for display-only fields (e.g. reference fields without chooser write-back yet). public bool IsEditable { get; } diff --git a/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs b/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs index b582547709..01d386c5e2 100644 --- a/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs +++ b/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs @@ -179,7 +179,7 @@ private static ViewNode StampProductLeaf(ViewNode source, string automationId, s => new ViewNode(source.StableId, ViewNodeKind.Field, labelOverride ?? source.Label, source.Abbreviation, source.Field, source.RawEditor, source.EditorClassification, source.WritingSystem, source.Visibility, source.Expansion, source.Indented, source.TargetLayout, null, - source.LocalizationKey, automationId, HostRouting.Product); + source.LocalizationKey, automationId, HostRouting.Product, helpTopicId: source.HelpTopicId); private static ViewNode Leaf(string stableId, string label, string field, string editor, string ws, string automationId) => new ViewNode(stableId, ViewNodeKind.Field, label, null, field, editor, diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs index 6c9956aa37..bffd46bc19 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs @@ -89,6 +89,7 @@ private static JObject WriteNode(ViewNode node) AddIfPresent(o, "menu", node.MenuId); AddIfPresent(o, "contextMenu", node.ContextMenuId); AddIfPresent(o, "hotlinks", node.HotlinksId); + AddIfPresent(o, "helpTopicID", node.HelpTopicId); AddIfPresent(o, "ghost", node.GhostField); AddIfPresent(o, "ghostWs", node.GhostWs); AddIfPresent(o, "ghostClass", node.GhostClass); @@ -212,7 +213,8 @@ private static ViewNode ReadNode(JToken token) (bool?)o["forVariant"] ?? false, ghostInitMethod: (string)o["ghostInitMethod"], condition: ReadCondition((JObject)o["condition"]), - chooserLinks: ((JArray)o["chooserLinks"])?.Select(ReadChooserLink).ToList()); + chooserLinks: ((JArray)o["chooserLinks"])?.Select(ReadChooserLink).ToList(), + helpTopicId: (string)o["helpTopicID"]); } 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 22730bc522..4e8834e557 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs @@ -386,8 +386,10 @@ public ViewNode( ViewStringList enumStringList = null, IReadOnlyList visibleWritingSystems = null, bool toggleValue = false, - bool reorder = false) + bool reorder = false, + string helpTopicId = null) { + HelpTopicId = helpTopicId; Reorder = reorder; ToggleValue = toggleValue; VisibleWritingSystems = visibleWritingSystems; @@ -565,6 +567,12 @@ public ViewNode( /// even when the property is virtual; the order persists as a virtual ordering. /// public bool Reorder { get; } + + /// + /// The slice's authored helpTopicID; null when the layout has none and the + /// topic is generated from the field, its object and the tool. + /// + public string HelpTopicId { get; } } /// diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs index 5fcbad085d..fb6e5e9484 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs @@ -309,7 +309,8 @@ private static ViewNode CloneWith(ViewNode n, ViewVisibility visibility, string n.LocalizationKey, n.AutomationId, n.Routing, n.BoldEmphasis, n.FontScalePercent, n.MenuId, 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); + n.ChooserLinks, n.EnumStringList, visibleWritingSystems, n.ToggleValue, n.Reorder, + n.HelpTopicId); // 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). @@ -320,6 +321,7 @@ private static ViewNode CloneWithId(ViewNode n, string newId) n.LocalizationKey, null, n.Routing, n.BoldEmphasis, n.FontScalePercent, n.MenuId, 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); + n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue, n.Reorder, + n.HelpTopicId); } } diff --git a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs index 9054b94462..c43b5b5298 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs @@ -36,7 +36,7 @@ public sealed class XmlLayoutImporter : IViewDefinitionImporter { "label", "abbr", "field", "ws", "editor", "visibility", "expansion", "localizationKey", "labelId", "automationId", "routing", "menu", "contextMenu", "hotlinks", - "forVariant", "visibleWritingSystems", "reorder" + "forVariant", "visibleWritingSystems", "reorder", "helpTopicID" }; public static readonly HashSet HandledObjSeqAttributes = @@ -394,7 +394,8 @@ private ViewNode CreateNode( localizationKey, automationId, routing, boldEmphasis, fontScalePercent, menuId, contextMenuId, hotlinksId, chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null, - visibleWritingSystems: visibleWss); + visibleWritingSystems: visibleWss, + helpTopicId: Attr(contentEl, "helpTopicID")); } // Dynamic custom slices keep their legacy class/assembly identity so the host can @@ -416,7 +417,8 @@ private ViewNode CreateNode( toggleValue: ParseOptionalBool(Attr(contentEl, "toggleValue")) ?? false, // The slice's reorder= attribute: its items may be reordered // even when the property is virtual. - reorder: ParseOptionalBool(Attr(contentEl, "reorder")) ?? false); + reorder: ParseOptionalBool(Attr(contentEl, "reorder")) ?? false, + helpTopicId: Attr(contentEl, "helpTopicID")); } case "obj": case "seq": diff --git a/Src/FdoUi/DetailRules/FieldHelpTopics.cs b/Src/FdoUi/DetailRules/FieldHelpTopics.cs new file mode 100644 index 0000000000..b1b266d7ce --- /dev/null +++ b/Src/FdoUi/DetailRules/FieldHelpTopics.cs @@ -0,0 +1,205 @@ +// 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.Text; +using SIL.FieldWorks.Common.FwUtils; + +// 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 field a help topic is resolved for: what the layout and the field's object say + /// about it, plus the area and tool showing it. Every value is a plain string so either + /// detail view can supply it. + /// + public sealed class HelpTopicSubject + { + /// The layout's field name (the field attribute); may be null. + public string FieldName { get; set; } + + /// The layout's raw, unlocalized label; may be null. + public string Label { get; set; } + + /// The class name of the field's object. + public string ClassName { get; set; } + + /// The class name of the object's owner; null for an unowned object. + public string OwnerClassName { get; set; } + + /// The object's sort key; consulted only for a CmPossibility. + public string SortKey { get; set; } + + /// + /// For a lexical-relation Targets field: true when the relation is shown under an + /// entry, false under a sense. Null for every other field. + /// + public bool? TargetsParentIsEntry { get; set; } + + /// The current area choice, e.g. "lexicon" or "lists". + public string AreaName { get; set; } + + /// The current tool, e.g. "lexiconEdit". + public string ToolName { get; set; } + } + + /// + /// Resolves the help topic id of a detail field: the layout's explicit id when it has one, + /// otherwise an id generated from the field, its object and the tool, taking the first + /// candidate the help provider knows. + /// + public static class FieldHelpTopics + { + /// The prefix of a field's own help topic. + public const string FieldPrefix = "khtpField"; + + /// The prefix of a field's chooser-dialog help topic. + public const string ChooserPrefix = "khtpChoose"; + + /// The topic offered when no generated candidate exists. + public const string NoHelpTopic = "khtpNoHelpTopic"; + + /// + /// The help topic for a field whose subject is already known. + /// + /// The layout's helpTopicID; null or empty to + /// generate one. + /// or . + /// The field being resolved. + /// Whether the help provider knows a topic id. + public static string Resolve(string explicitTopicId, string prefix, HelpTopicSubject subject, + Func topicExists) + { + if (subject == null) + throw new ArgumentNullException(nameof(subject)); + return Resolve(explicitTopicId, prefix, () => subject, topicExists); + } + + /// + /// The help topic for a field, describing the subject only when one has to be + /// generated: an explicit id returns before is called. + /// + /// The layout's helpTopicID; null or empty to + /// generate one. + /// or . + /// Describes the field; called at most once. + /// Whether the help provider knows a topic id. + public static string Resolve(string explicitTopicId, string prefix, Func subject, + Func topicExists) + { + if (subject == null) + throw new ArgumentNullException(nameof(subject)); + if (topicExists == null) + throw new ArgumentNullException(nameof(topicExists)); + + var authored = NormalizeExplicit(explicitTopicId); + if (authored != null) + return authored; + var described = subject() ?? throw new InvalidOperationException("The help-topic subject is null."); + return Generate(prefix, described, topicExists); + } + + /// + /// The layout's authored topic as the product uses it, or null when there is none. + /// The rule-formula field's authored topic points at the environment chooser topic. + /// + public static string NormalizeExplicit(string explicitTopicId) + { + if (explicitTopicId == "khtpField-PhRegularRule-RuleFormula") + return "khtpChoose-Environment"; + return string.IsNullOrEmpty(explicitTopicId) ? null : explicitTopicId; + } + + /// + /// The topic-exists predicate for a help provider: a non-empty id the provider has a + /// string for. A null provider knows nothing. + /// + public static Func KnownBy(IHelpTopicProvider provider) + => id => provider != null && !string.IsNullOrEmpty(id) && provider.GetHelpString(id) != null; + + private static string Generate(string prefix, HelpTopicSubject subject, Func topicExists) + { + // Cross-reference (entry level) and lexical-relation (sense level) subitems share one + // topic per level. + if (subject.FieldName == "Targets" && subject.TargetsParentIsEntry.HasValue) + { + return subject.TargetsParentIsEntry.Value + ? prefix + "-" + subject.ToolName + "-CrossReferenceSubitem" + : prefix + "-" + subject.ToolName + "-LexicalRelationSubitem"; + } + + var labelId = ToAlphanumericId(subject.Label); + var fieldName = string.IsNullOrEmpty(subject.FieldName) ? labelId : subject.FieldName; + var candidate = GenerateForField(prefix, fieldName, subject, topicExists); + if (topicExists(candidate)) + return candidate; + // The field attribute gave nothing the provider knows; try the label. + candidate = GenerateForField(prefix, labelId, subject, topicExists); + if (topicExists(candidate)) + return candidate; + if (prefix == ChooserPrefix) + return "khtpChoose-CmPossibility"; + // An undefined list falls back to the generic list-field topic. + return subject.AreaName == "lists" ? "khtp-CustomListField" : NoHelpTopic; + } + + // Candidates from most to least specific: tool+class+field, the possibility's sort key, + // tool+field, class+field, field. The last is returned unverified. + private static string GenerateForField(string prefix, string fieldName, HelpTopicSubject subject, + Func topicExists) + { + var className = subject.ClassName; + // An Example or Translation under an extended note has its own topics. + if ((fieldName == "Example" || (fieldName ?? string.Empty).StartsWith("Translation", StringComparison.Ordinal)) + && subject.OwnerClassName == "LexExtendedNote") + { + className = "LexExtendedNote"; + } + var tool = subject.ToolName; + + var candidate = prefix + "-" + tool + "-" + className + "-" + fieldName; + if (topicExists(candidate)) + return candidate; + if (className == "CmPossibility") + { + candidate = prefix + "-" + tool + "-" + subject.SortKey + "-" + fieldName; + if (topicExists(candidate)) + return candidate; + } + candidate = prefix + "-" + tool + "-" + fieldName; + if (topicExists(candidate)) + return candidate; + candidate = prefix + "-" + className + "-" + fieldName; + if (topicExists(candidate)) + return candidate; + return prefix + "-" + fieldName; + } + + /// + /// A label reduced to a topic-id fragment: letters and digits only, each run + /// capitalized ("Complex Forms" becomes "ComplexForms"). Empty for a null label. + /// + public static string ToAlphanumericId(string label) + { + var id = new StringBuilder(); + if (string.IsNullOrEmpty(label)) + return string.Empty; + var nextCapital = true; + foreach (var ch in label) + { + if (char.IsLetterOrDigit(ch)) + { + id.Append(nextCapital ? char.ToUpper(ch) : ch); + nextCapital = false; + } + else + { + nextCapital = true; + } + } + return id.ToString(); + } + } +} diff --git a/Src/FdoUi/FdoUiTests/DetailRules/DetailRulesBoundaryTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/DetailRulesBoundaryTests.cs new file mode 100644 index 0000000000..078a4882d6 --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/DetailRulesBoundaryTests.cs @@ -0,0 +1,110 @@ +// 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.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using NUnit.Framework; +using SIL.FieldWorks.Common.DetailRules; + +namespace SIL.FieldWorks.FdoUi +{ + /// + /// The DetailRules boundary: rules shared by the WinForms and Avalonia detail views take + /// domain objects and plain values only, so they keep working once WinForms is removed. + /// One test checks every signature in the namespace, the other the folder's using + /// directives, which is where a method body would pull a UI stack in. + /// + [TestFixture] + public class DetailRulesBoundaryTests + { + private static readonly string[] ForbiddenAssemblyPrefixes = + { + "System.Windows.Forms", "System.Drawing", "Avalonia", "DetailControls", "FwControls", + "RootSite", "SimpleRootSite", "XMLViews", "Framework", "FwAvalonia" + }; + + private static readonly Regex ForbiddenUsing = new Regex( + @"^\s*using\s+(static\s+)?(System\.Windows\.Forms|System\.Drawing|Avalonia|SIL\.FieldWorks\.Common\.Framework\.DetailControls|SIL\.FieldWorks\.Common\.Controls|SIL\.FieldWorks\.Common\.FwAvalonia|SIL\.FieldWorks\.Common\.RootSites)", + RegexOptions.Compiled); + + [Test] + public void DetailRulesTypes_ReferenceNoUiStackInTheirSignatures() + { + var rulesNamespace = typeof(FieldHelpTopics).Namespace; + var types = typeof(FieldHelpTopics).Assembly.GetTypes() + .Where(t => t.Namespace == rulesNamespace).ToList(); + Assert.That(types, Is.Not.Empty); + + var offences = new List(); + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance + | BindingFlags.Static | BindingFlags.DeclaredOnly; + foreach (var type in types) + { + Check(type.BaseType, type.Name + " : base", offences); + foreach (var i in type.GetInterfaces()) + Check(i, type.Name + " : interface", offences); + foreach (var f in type.GetFields(all)) + Check(f.FieldType, type.Name + "." + f.Name, offences); + foreach (var p in type.GetProperties(all)) + Check(p.PropertyType, type.Name + "." + p.Name, offences); + foreach (var m in type.GetMethods(all).Cast().Concat(type.GetConstructors(all))) + { + if (m is MethodInfo mi) + Check(mi.ReturnType, type.Name + "." + m.Name + " returns", offences); + foreach (var prm in m.GetParameters()) + Check(prm.ParameterType, type.Name + "." + m.Name + "(" + prm.Name + ")", offences); + } + } + Assert.That(offences, Is.Empty, "DetailRules must not depend on a UI stack"); + } + + [Test] + public void DetailRulesSources_ImportNoUiStackNamespace() + { + var folder = FindDetailRulesFolder(); + Assume.That(folder, Is.Not.Null, "the repository sources are not beside the test output"); + var offences = new List(); + foreach (var file in Directory.GetFiles(folder, "*.cs", SearchOption.AllDirectories)) + { + var lines = File.ReadAllLines(file); + for (var i = 0; i < lines.Length; i++) + { + if (ForbiddenUsing.IsMatch(lines[i])) + offences.Add(Path.GetFileName(file) + ":" + (i + 1) + " " + lines[i].Trim()); + } + } + Assert.That(offences, Is.Empty, "DetailRules sources must not import a UI stack"); + } + + // The repo's Src/FdoUi/DetailRules, found by walking up from the test output folder. + private static string FindDetailRulesFolder() + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "Src", "FdoUi", "DetailRules"); + if (Directory.Exists(candidate)) + return candidate; + } + return null; + } + + private static void Check(Type type, string site, List offences) + { + if (type == null) + return; + if (type.IsGenericType) + { + foreach (var arg in type.GetGenericArguments()) + Check(arg, site, offences); + } + var assembly = type.Assembly.GetName().Name; + if (ForbiddenAssemblyPrefixes.Any(p => assembly.StartsWith(p, StringComparison.Ordinal))) + offences.Add(site + " -> " + type.FullName); + } + } +} diff --git a/Src/FdoUi/FdoUiTests/DetailRules/FieldHelpTopicsTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/FieldHelpTopicsTests.cs new file mode 100644 index 0000000000..deb231fa33 --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/FieldHelpTopicsTests.cs @@ -0,0 +1,150 @@ +// 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 NUnit.Framework; +using SIL.FieldWorks.Common.DetailRules; + +namespace SIL.FieldWorks.FdoUi +{ + /// + /// The help-topic rule against a provider that knows a chosen set of topics, so each + /// fallback level is reached deliberately. + /// + [TestFixture] + public class FieldHelpTopicsTests + { + private static Func Knows(params string[] topics) + { + var known = new HashSet(topics, StringComparer.Ordinal); + return known.Contains; + } + + private static HelpTopicSubject Gloss() + => new HelpTopicSubject + { + FieldName = "Gloss", Label = "Gloss", ClassName = "LexSense", + OwnerClassName = "LexEntry", AreaName = "lexicon", ToolName = "lexiconEdit" + }; + + [Test] + public void ExplicitTopic_Wins_EvenWhenUnknown() + { + Assert.That(FieldHelpTopics.Resolve("khtpField-Authored", FieldHelpTopics.FieldPrefix, Gloss(), Knows()), + Is.EqualTo("khtpField-Authored")); + } + + [Test] + public void TheRuleFormulaTopic_RedirectsToTheEnvironmentChooser() + { + Assert.That(FieldHelpTopics.Resolve("khtpField-PhRegularRule-RuleFormula", FieldHelpTopics.FieldPrefix, + Gloss(), Knows()), Is.EqualTo("khtpChoose-Environment")); + } + + [Test] + public void Generated_PrefersToolClassField() + { + var known = Knows("khtpField-lexiconEdit-LexSense-Gloss", "khtpField-lexiconEdit-Gloss", "khtpField-Gloss"); + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, Gloss(), known), + Is.EqualTo("khtpField-lexiconEdit-LexSense-Gloss")); + } + + [Test] + public void Generated_FallsThroughToolField_ClassField_ThenField() + { + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, Gloss(), Knows("khtpField-lexiconEdit-Gloss")), + Is.EqualTo("khtpField-lexiconEdit-Gloss")); + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, Gloss(), Knows("khtpField-LexSense-Gloss")), + Is.EqualTo("khtpField-LexSense-Gloss")); + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, Gloss(), Knows("khtpField-Gloss")), + Is.EqualTo("khtpField-Gloss")); + } + + [Test] + public void Generated_TriesTheLabel_WhenTheFieldNameYieldsNothing() + { + var subject = Gloss(); + subject.Label = "Complex Forms"; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, subject, + Knows("khtpField-lexiconEdit-LexSense-ComplexForms")), + Is.EqualTo("khtpField-lexiconEdit-LexSense-ComplexForms")); + } + + [Test] + public void Generated_UsesTheLabel_WhenThereIsNoFieldName() + { + var subject = Gloss(); + subject.FieldName = null; + subject.Label = "Semantic Domains"; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, subject, Knows("khtpField-SemanticDomains")), + Is.EqualTo("khtpField-SemanticDomains")); + } + + [Test] + public void Generated_NothingKnown_GivesTheNoTopicDefault_OrTheListDefault() + { + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, Gloss(), Knows()), + Is.EqualTo(FieldHelpTopics.NoHelpTopic)); + var list = Gloss(); + list.AreaName = "lists"; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, list, Knows()), + Is.EqualTo("khtp-CustomListField")); + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.ChooserPrefix, Gloss(), Knows()), + Is.EqualTo("khtpChoose-CmPossibility")); + } + + [Test] + public void Generated_Possibility_TriesItsSortKey() + { + var subject = new HelpTopicSubject + { + FieldName = "Name", ClassName = "CmPossibility", SortKey = "Genres", + AreaName = "lists", ToolName = "genresEdit" + }; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, subject, Knows("khtpField-genresEdit-Genres-Name")), + Is.EqualTo("khtpField-genresEdit-Genres-Name")); + } + + [Test] + public void Generated_ExtendedNoteExampleAndTranslation_UseTheNoteClass() + { + var example = new HelpTopicSubject + { + FieldName = "Example", ClassName = "LexExampleSentence", OwnerClassName = "LexExtendedNote", + ToolName = "lexiconEdit" + }; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, example, + Knows("khtpField-lexiconEdit-LexExtendedNote-Example")), + Is.EqualTo("khtpField-lexiconEdit-LexExtendedNote-Example")); + var translation = new HelpTopicSubject + { + FieldName = "Translations", ClassName = "LexExampleSentence", OwnerClassName = "LexExtendedNote", + ToolName = "lexiconEdit" + }; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, translation, + Knows("khtpField-lexiconEdit-LexExtendedNote-Translations")), + Is.EqualTo("khtpField-lexiconEdit-LexExtendedNote-Translations")); + } + + [Test] + public void Targets_UnderAnEntry_IsACrossReference_UnderASense_ALexicalRelation() + { + var entry = new HelpTopicSubject { FieldName = "Targets", ToolName = "lexiconEdit", TargetsParentIsEntry = true }; + var sense = new HelpTopicSubject { FieldName = "Targets", ToolName = "lexiconEdit", TargetsParentIsEntry = false }; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, entry, Knows()), + Is.EqualTo("khtpField-lexiconEdit-CrossReferenceSubitem")); + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, sense, Knows()), + Is.EqualTo("khtpField-lexiconEdit-LexicalRelationSubitem")); + } + + [Test] + public void ToAlphanumericId_CapitalizesEachRun_AndDropsTheRest() + { + Assert.That(FieldHelpTopics.ToAlphanumericId("Complex Forms"), Is.EqualTo("ComplexForms")); + Assert.That(FieldHelpTopics.ToAlphanumericId("semantic-domain 2"), Is.EqualTo("SemanticDomain2")); + Assert.That(FieldHelpTopics.ToAlphanumericId(null), Is.EqualTo(string.Empty)); + } + } +} diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 22ab9ea4dc..a2ff057a93 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -385,10 +385,39 @@ private void AddField(DetailField field) field.ClassName = ctx.ClassName; field.LayoutName = ctx.LayoutName; } + StampHelpTopic(field); Fields.Add(field); } + // The node whose walk is adding rows right now (see Walk). + private readonly Stack _walkNodes = new Stack(); + + // A row takes its help-topic inputs from the node being walked unless the site set + // them itself (the lexical-relation rows). + private void StampHelpTopic(DetailField field) + { + if (_walkNodes.Count == 0) + return; + var node = _walkNodes.Peek(); + if (field.HelpTopicId == null) + field.HelpTopicId = node.HelpTopicId; + if (field.HelpTopicSource == null) + field.HelpTopicSource = HelpTopicSourceFor(node.Field, node.Label, field.ObjectHvo); + } + + // The object-derived half of the help-topic inputs; null when the row has no object. + private DetailHelpTopicSource HelpTopicSourceFor(string fieldName, string label, int hvo, + bool? targetsParentIsEntry = null) + { + if (hvo == 0 || !_cache.ServiceLocator.ObjectRepository.TryGetObject(hvo, out var obj)) + return null; + // Only a possibility's topic can use the sort key, and computing it is not free. + var sortKey = obj is ICmPossibility ? obj.SortKey : null; + return new DetailHelpTopicSource(fieldName, label, _mdc.GetClassName(obj.ClassID), + obj.Owner?.ClassName, sortKey, targetsParentIsEntry); + } + // The project's character-type style names, sourced from // Cache.LangProject.StylesOC (the LcmStyleSheet's backing store) and filtered to // StyleType.kstCharacter -- the same set the legacy character-style combo offers. @@ -577,6 +606,20 @@ public void Walk(ViewNode node, ICmObject obj, int depth) if (IsHidden(node) || depth > MaxDepth || IsIrrelevantForObject(node, obj)) return; + // Rows added while this node walks are stamped with its help-topic inputs. + _walkNodes.Push(node); + try + { + WalkByKind(node, obj, depth); + } + finally + { + _walkNodes.Pop(); + } + } + + private void WalkByKind(ViewNode node, ICmObject obj, int depth) + { switch (node.Kind) { case ViewNodeKind.Field: @@ -1831,13 +1874,18 @@ private void AddLexicalRelationRows(ViewNode node, ICmObject obj, int depth) if (row.IsEditable) searchOptions = query => SearchLexicalRelationTargets(query, obj, relation, row.MappingType); - AddField(new DetailField(stableId, row.Label, node.Field, node.WritingSystem, + var relationRow = new DetailField(stableId, row.Label, node.Field, node.WritingSystem, DetailFieldKind.ReferenceVector, node.EditorClassification, node.AutomationId, node.LocalizationKey, node.Routing, null, null, null, isEditable: row.IsEditable, indent: depth, menuId: row.MenuId, contextMenuId: node.ContextMenuId, hotlinksId: node.HotlinksId, objectHvo: relation.Hvo, items: items, - searchOptions: searchOptions)); + searchOptions: searchOptions); + // WinForms shows a relation as a Targets slice under the entry or sense, and + // keys its help topic on that. + relationRow.HelpTopicSource = HelpTopicSourceFor("Targets", row.Label, relation.Hvo, + targetsParentIsEntry: obj is ILexEntry); + AddField(relationRow); if (!row.IsEditable) continue; diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index cf448f708a..2533d27e77 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Windows.Forms; using System.Xml; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.FwAvalonia; using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.Common.FwAvalonia.Seams; @@ -594,6 +595,34 @@ private ViewDefinitionOverrideStore ViewOverrideStore } } + /// + /// The help topic of a detail row: its when set, + /// else one generated from the row's field and object and the current tool. Null when + /// the row carries nothing to generate from. + /// + internal string ResolveHelpTopic(DetailField field) + { + if (field == null) + return null; + var source = field.HelpTopicSource; + if (string.IsNullOrEmpty(field.HelpTopicId) && source == null) + return null; + var provider = m_propertyTable.GetValue("HelpTopicProvider"); + var subject = new HelpTopicSubject + { + FieldName = source?.FieldName, + Label = source?.Label, + ClassName = source?.ClassName, + OwnerClassName = source?.OwnerClassName, + SortKey = source?.SortKey, + TargetsParentIsEntry = source?.TargetsParentIsEntry, + AreaName = m_propertyTable.GetStringProperty("areaChoice", null), + ToolName = m_propertyTable.GetStringProperty("currentContentControl", null) + }; + return FieldHelpTopics.Resolve(field.HelpTopicId, FieldHelpTopics.FieldPrefix, subject, + FieldHelpTopics.KnownBy(provider)); + } + // The resolver the composer calls for each compiled (class, layout); null result = shipped // definition. A load failure is logged, not fatal -- compose then uses the shipped // definition. diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs index f91c54469a..5a9edc7fe8 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs @@ -299,6 +299,90 @@ public void SubentriesMoveRight_ThroughTheHost_ReordersTheRow() "the host re-shows the record after the coalesced completion"); } + // Help topics: every composed row that has a WinForms slice twin must resolve the SAME + // topic id the slice generates, with the provider knowing nothing and knowing much. + + private sealed class PatternHelpTopicProvider : IHelpTopicProvider + { + private readonly Func _knows; + public PatternHelpTopicProvider(Func knows) { _knows = knows; } + public string GetHelpString(string id) => id != null && _knows(id) ? id : null; + public string HelpFile => string.Empty; + } + + // A lexical relation from the test entry to another, so the entry composes a relation + // row whose WinForms twin is a Targets slice. + private void MakeLexicalRelation() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var stem = GetMorphTypeOrCreateOne("stem"); + var noun = GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA); + var other = AddLexeme(m_createdObjects, "command-entry-synonym", stem, "synonym", noun); + var lexDb = Cache.LangProject.LexDbOA; + if (lexDb.ReferencesOA == null) + lexDb.ReferencesOA = Cache.ServiceLocator.GetInstance().Create(); + var type = Cache.ServiceLocator.GetInstance().Create(); + lexDb.ReferencesOA.PossibilitiesOS.Add(type); + type.MappingType = (int)LexRefTypeTags.MappingTypes.kmtEntryCollection; + type.Name.set_String(Cache.DefaultAnalWs, "Synonyms"); + var reference = Cache.ServiceLocator.GetInstance().Create(); + type.MembersOC.Add(reference); + reference.TargetsRS.Add(m_entry); + reference.TargetsRS.Add(other); + }); + DrainMediatorAndIdleQueues(); + } + + [TestCase(false)] + [TestCase(true)] + public void HelpTopics_ResolveLikeTheWinFormsSlices_ForEveryTwinnedRow(bool providerKnowsToolTopics) + { + AddSense("second gloss"); + MakeTwoSubentries(); + MakeLexicalRelation(); + // The provider decides which generated candidate wins; both sides read the same one. + m_propertyTable.SetProperty("HelpTopicProvider", + new PatternHelpTopicProvider(id => providerKnowsToolTopics && id.Contains("-lexiconEdit-")), false); + m_propertyTable.SetPropertyPersistence("HelpTopicProvider", false); + EnsureAdapter(m_entry.Hvo); + var dataTree = (LegacyDataTree)GetField(m_view, "m_dataEntryForm"); + var fields = DetailComposer.Compose(m_entry, Cache).Model.Fields; + + var compared = 0; + var relationsCompared = 0; + var mismatches = new List(); + foreach (var sliceObj in dataTree.Slices) + { + if (!(sliceObj is Slice slice) || slice.Object == null || slice.IsLazyPlaceholder || slice.Flid == 0) + continue; + var mdc = (IFwMetaDataCacheManaged)Cache.MetaDataCacheAccessor; + var sliceField = mdc.FieldExists(slice.Flid) ? mdc.GetFieldName(slice.Flid) : null; + if (sliceField == null) + continue; + // A relation's WinForms twin is a Targets slice on the relation; the row keeps + // the + // relations property as its field and names Targets in its help-topic source. + var twin = sliceField == "Targets" + ? fields.FirstOrDefault(f => f.ObjectHvo == slice.Object.Hvo + && f.HelpTopicSource?.FieldName == "Targets") + : fields.FirstOrDefault(f => f.ObjectHvo == slice.Object.Hvo + && string.Equals(f.Field, sliceField, StringComparison.Ordinal)); + if (twin == null) + continue; + compared++; + if (sliceField == "Targets") + relationsCompared++; + var expected = slice.GetSliceHelpTopicID(); + var actual = m_view.ResolveHelpTopic(twin); + if (!string.Equals(expected, actual, StringComparison.Ordinal)) + mismatches.Add($"{sliceField} on {slice.Object.ClassName}: slice '{expected}', row '{actual}'"); + } + Assert.That(compared, Is.GreaterThan(5), "enough rows have a WinForms twin to make the comparison meaningful"); + Assert.That(relationsCompared, Is.GreaterThan(0), "the lexical relation's Targets slice found its row"); + Assert.That(mismatches, Is.Empty); + } + // The reorder-vector menu's native authority and its equivalence net: the rendered tree // must match the adapter's for every state a row can be in.