diff --git a/DistFiles/Language Explorer/Configuration/Grammar/areaConfiguration.xml b/DistFiles/Language Explorer/Configuration/Grammar/areaConfiguration.xml
index 36a9dc33aa..1c9dfebc13 100644
--- a/DistFiles/Language Explorer/Configuration/Grammar/areaConfiguration.xml
+++ b/DistFiles/Language Explorer/Configuration/Grammar/areaConfiguration.xml
@@ -439,7 +439,9 @@
-
+
+
+
diff --git a/Src/LexText/Morphology/AffixRuleFormulaControl.cs b/Src/LexText/Morphology/AffixRuleFormulaControl.cs
index 96d776c50b..e2dba908c2 100644
--- a/Src/LexText/Morphology/AffixRuleFormulaControl.cs
+++ b/Src/LexText/Morphology/AffixRuleFormulaControl.cs
@@ -841,7 +841,9 @@ public override void SetMappingNaturalClass(SelectionHelper sel = null)
var natClasses = new HashSet();
foreach (var nc in m_cache.LangProject.PhonologicalDataOA.NaturalClassesOS)
{
- if (nc.ClassID == PhNCFeaturesTags.kClassId)
+ // Rule mappings only allow feature-based, user-defined natural classes
+ // to be selected.
+ if (nc is IPhNCFeatures featNC && IsFeatureBasedNCUserDefined(featNC))
natClasses.Add(nc);
}
var selectedNc = DisplayChooser(MEStrings.ksRuleNCOpt, MEStrings.ksRuleNCChooserLink,
diff --git a/Src/LexText/Morphology/AffixRuleFormulaSlice.cs b/Src/LexText/Morphology/AffixRuleFormulaSlice.cs
index 8a54d8fa56..8c7f5a872a 100644
--- a/Src/LexText/Morphology/AffixRuleFormulaSlice.cs
+++ b/Src/LexText/Morphology/AffixRuleFormulaSlice.cs
@@ -62,7 +62,11 @@ public bool OnMappingSetNaturalClass(object args)
public bool OnDisplayMappingJumpToNaturalClass(object commandObject, ref XCore.UIItemDisplayProperties display)
{
CheckDisposed();
- bool enable = AffixRuleFormulaControl.IsNCIndexCurrent;
+ // Note that a mapping's Natural Class is always feature-based.
+ // For feature-based NCs, we only want to allow jumping to the NC
+ // if it is user-defined.
+ var mapping = AffixRuleFormulaControl.CurrentObject as IMoModifyFromInput;
+ bool enable = mapping != null && RuleFormulaControl.IsFeatureBasedNCUserDefined(mapping.ModificationRA);
display.Enabled = enable;
display.Visible = enable;
return true;
diff --git a/Src/LexText/Morphology/MorphologyEditorDllTests/NaturalClassRecordListTests.cs b/Src/LexText/Morphology/MorphologyEditorDllTests/NaturalClassRecordListTests.cs
new file mode 100644
index 0000000000..9ad1952579
--- /dev/null
+++ b/Src/LexText/Morphology/MorphologyEditorDllTests/NaturalClassRecordListTests.cs
@@ -0,0 +1,64 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using SIL.LCModel;
+
+namespace SIL.FieldWorks.XWorks.MorphologyEditor
+{
+ ///
+ /// Tests to confirm that includes every
+ /// natural class except a feature-based one generated automatically for a rule.
+ ///
+ [TestFixture]
+ public class NaturalClassRecordListTests : RuleFormulaVcTestBase
+ {
+ private IEnumerable NCRecordList(params ICmObject[] natClasses)
+ {
+ return NaturalClassRecordList.ExcludeAutoGenerated(
+ natClasses.Select(nc => nc.Hvo), Cache);
+ }
+ [Test]
+ public void AutoGeneratedClass_IsExcluded()
+ {
+ IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
+ new Dictionary { { "vd", "+" } });
+
+ // Since the only NC is autogenerated, the NC record list should be empty
+ Assert.That(NCRecordList(natClass), Is.Empty);
+ }
+
+ [Test]
+ public void NamedFeatureClass_IsIncluded()
+ {
+ IPhNCFeatures natClass = AddFeatureNaturalClass("Voiced", "Vd",
+ new Dictionary { { "vd", "+" } });
+
+ Assert.That(NCRecordList(natClass), Is.EqualTo(new[] { natClass.Hvo }));
+ }
+
+ ///
+ /// A feature-based class the user creates starts with no name, so an unnamed class must
+ /// be included for the user to be able to name it.
+ ///
+ [Test]
+ public void UnnamedFeatureClass_IsIncluded()
+ {
+ IPhNCFeatures natClass = AddFeatureNaturalClass(null, null,
+ new Dictionary { { "vd", "+" } });
+
+ Assert.That(NCRecordList(natClass), Is.EqualTo(new[] { natClass.Hvo }));
+ }
+
+ [Test]
+ public void SegmentClass_IsIncluded()
+ {
+ IPhNCSegments natClass = AddSegmentNaturalClass("Vowels", "V");
+
+ Assert.That(NCRecordList(natClass), Is.EqualTo(new[] { natClass.Hvo }));
+ }
+ }
+}
diff --git a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaControlNaturalClassNameTests.cs b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaControlNaturalClassNameTests.cs
index 17e2139aa2..a386a905cd 100644
--- a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaControlNaturalClassNameTests.cs
+++ b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaControlNaturalClassNameTests.cs
@@ -21,56 +21,62 @@ public class RuleFormulaControlNaturalClassNameTests : RuleFormulaVcTestBase
[Test]
public void NoClass_IsNotUserDefined()
{
- Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(null), Is.False);
+ Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(null), Is.False);
}
///
- /// The check answers whether a class carries a name the user gave it, and an unnamed
- /// class carries none. Set Phonological Features relies on that answer to keep
- /// offering feature edits on unnamed classes (LT-22576).
+ /// A user defined class can have no name, while an auto-generated one will always have
+ /// a generated name. Check that if the class has no name, it is flagged as user defined.
///
[Test]
- public void ClassWithNoName_IsNotUserDefined()
+ public void ClassWithNoName_IsUserDefined()
{
IPhNCFeatures natClass = AddFeatureNaturalClass(null, "Vd",
new FeatVals { { "vd", "+" } });
- Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.False,
- "an unnamed class carries no name the user gave it");
+ Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.True,
+ "an unnamed class must be user defined");
}
+ ///
+ /// A user defined class can have an empty name, while an auto-generated one will always
+ /// have a nonempty generated name. Check that if the class's name is empty,
+ /// it is flagged as user defined.
+ ///
[Test]
- public void ClassWithAnEmptyName_IsNotUserDefined()
+ public void ClassWithAnEmptyName_IsUserDefined()
{
IPhNCFeatures natClass = AddFeatureNaturalClass(string.Empty, "Vd",
new FeatVals { { "vd", "+" } });
- Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.False,
- "an empty name is not a name the user gave the class");
+ Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.True,
+ "a class with an empty name must be user defined");
}
[Test]
public void ClassNamedForARule_IsNotUserDefined()
{
- IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
+ IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" } });
- Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.False);
+ Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.False);
}
///
- /// Projects hold rule-generated names that carry a prefix ahead of the generated text, so
- /// the generated text is recognised wherever it sits in the name.
+ /// If a project contains a NC whose name was generated but a user has added text before
+ /// or after the generated name, it is user defined.
///
[Test]
- public void ClassNamedForARuleBehindAPrefix_IsNotUserDefined()
+ public void ClassNamedForARuleWithUserAssignedPrefixOrPostfix_IsUserDefined()
{
IPhNCFeatures natClass = AddFeatureNaturalClass(null, null,
new FeatVals { { "vd", "+" } });
natClass.Name.SetUserWritingSystem("Phonemes s and t - "
+ string.Format(MEStrings.ksRuleNCFeatsName, "s to n"));
-
- Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.False);
+ Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.True);
+ natClass.Name.SetUserWritingSystem(string.Format(MEStrings.ksRuleNCFeatsName, "s to n")
+ + " - phonemes s and t");
+ Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.True);
}
[Test]
@@ -79,7 +85,7 @@ public void NamedClass_IsUserDefined()
IPhNCFeatures natClass = AddFeatureNaturalClass("Voiced", "Vd",
new FeatVals { { "vd", "+" } });
- Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.True);
+ Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.True);
}
///
@@ -101,7 +107,7 @@ public void ClassNamedInAnAnalysisWritingSystemOtherThanTheUserOne_IsUserDefined
"the name must reach the check through the analysis writing system, "
+ "not through a placeholder");
- Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.True);
+ Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.True);
}
[Test]
@@ -109,8 +115,44 @@ public void NamedClassWithNoFeatures_IsUserDefined()
{
IPhNCFeatures natClass = AddFeatureNaturalClass("Voiced", "Vd", new FeatVals());
- Assert.That(RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass), Is.True,
+ Assert.That(RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass), Is.True,
"a name qualifies a class whatever it carries for features");
}
+
+ ///
+ /// No class is neither user defined nor auto-generated.
+ ///
+ [Test]
+ public void NullClass_IsNotAutoGenerated()
+ {
+ Assert.That(RuleFormulaControl.IsAutoGeneratedNaturalClass(null), Is.False);
+ }
+
+ ///
+ /// A translation may put the rule name first, leaving no generated text before the
+ /// placeholder. Its generated names are still recognized, but ordinary names are not.
+ ///
+ [Test]
+ public void FormatStartingWithRuleName_RecognisesGeneratedName()
+ {
+ const string format = "{0} rule: created automatically";
+
+ Assert.That(RuleFormulaControl.IsAutoGeneratedName(string.Format(format, "s to n"), format),
+ Is.True, "a name generated from the format is recognized");
+ Assert.That(RuleFormulaControl.IsAutoGeneratedName("Voiced", format), Is.False,
+ "an empty stub before the rule name must not match every name");
+ }
+
+ ///
+ /// A translation lacking the rule name placeholder must not break recognition; no name
+ /// counts as generated from it.
+ ///
+ [Test]
+ public void FormatWithoutPlaceholder_RecognisesNothing()
+ {
+ const string format = "Created automatically for rule";
+
+ Assert.That(RuleFormulaControl.IsAutoGeneratedName(format, format), Is.False);
+ }
}
}
diff --git a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaControlSharedNaturalClassTests.cs b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaControlSharedNaturalClassTests.cs
new file mode 100644
index 0000000000..ff668ce325
--- /dev/null
+++ b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaControlSharedNaturalClassTests.cs
@@ -0,0 +1,226 @@
+// 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 NUnit.Framework;
+using SIL.LCModel;
+using FeatVals = System.Collections.Generic.Dictionary;
+
+namespace SIL.FieldWorks.XWorks.MorphologyEditor
+{
+ ///
+ /// A natural class with a generated name counts as user defined once two or more distinct
+ /// rules use it, and stays auto-generated while at most one rule does. Each case varies
+ /// only what refers to the class.
+ ///
+ [TestFixture]
+ public class RuleFormulaControlSharedNaturalClassTests : RuleFormulaVcTestBase
+ {
+ private IPhNCFeatures AddGeneratedClass()
+ {
+ return AddAutoGeneratedFeatureNaturalClass("s to n", new FeatVals { { "vd", "+" } });
+ }
+
+ private static bool IsUserDefined(IPhNCFeatures natClass)
+ {
+ return RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass);
+ }
+
+ ///
+ /// Adds a sequence context owned as the left context of , the
+ /// way the rule editor holds an environment of more than one item. The members must
+ /// already be owned.
+ ///
+ private IPhSequenceContext AddRuleOwnedSequenceContext(IPhSegRuleRHS rhs,
+ params IPhPhonContext[] members)
+ {
+ IPhSequenceContext ctxt =
+ Cache.ServiceLocator.GetInstance().Create();
+ rhs.LeftContextOA = ctxt;
+ foreach (IPhPhonContext member in members)
+ ctxt.MembersRS.Add(member);
+ return ctxt;
+ }
+
+ ///
+ /// Adds an output mapping to that modifies its input with
+ /// .
+ ///
+ private IMoModifyFromInput AddModifyMapping(IMoAffixProcess rule, IPhNCFeatures natClass)
+ {
+ IMoModifyFromInput mapping =
+ Cache.ServiceLocator.GetInstance().Create();
+ rule.OutputOS.Add(mapping);
+ mapping.ModificationRA = natClass;
+ return mapping;
+ }
+
+ ///
+ /// Adds an environment whose left context is , used by an affix
+ /// allomorph of a new entry.
+ ///
+ private IPhEnvironment AddEnvironmentUsedByAnAllomorph(IPhPhonContext ctxt)
+ {
+ IPhEnvironment env =
+ Cache.ServiceLocator.GetInstance().Create();
+ Cache.LanguageProject.PhonologicalDataOA.EnvironmentsOS.Add(env);
+ env.LeftContextRA = ctxt;
+ ILexEntry entry = Cache.ServiceLocator.GetInstance().Create();
+ IMoAffixAllomorph allomorph =
+ Cache.ServiceLocator.GetInstance().Create();
+ entry.AlternateFormsOS.Add(allomorph);
+ allomorph.PhoneEnvRC.Add(env);
+ return env;
+ }
+
+ [Test]
+ public void AutoNamedClassUsedByTwoRegularRules_IsUserDefined()
+ {
+ IPhNCFeatures natClass = AddGeneratedClass();
+ AddStrucDescNCContext(AddRegularRule("rule A"), natClass);
+ AddStrucDescNCContext(AddRegularRule("rule B"), natClass);
+
+ Assert.That(IsUserDefined(natClass), Is.True);
+ }
+
+ [Test]
+ public void AutoNamedClassUsedByRegularAndMetathesisRules_IsUserDefined()
+ {
+ IPhNCFeatures natClass = AddGeneratedClass();
+ AddStrucDescNCContext(AddRegularRule("rule A"), natClass);
+ AddMetathesisNCContext(AddMetathesisRule("rule B"),
+ PhMetathesisRuleTags.kidxLeftSwitch, natClass);
+
+ Assert.That(IsUserDefined(natClass), Is.True);
+ }
+
+ [Test]
+ public void AutoNamedClassUsedByRegularAndAffixProcessRules_IsUserDefined()
+ {
+ IPhNCFeatures natClass = AddGeneratedClass();
+ AddStrucDescNCContext(AddRegularRule("rule A"), natClass);
+ AddInputNCContext(AddAffixProcessRule(), natClass);
+
+ Assert.That(IsUserDefined(natClass), Is.True);
+ }
+
+ [Test]
+ public void AutoNamedClassUsedByTwoAffixProcessMappings_IsUserDefined()
+ {
+ IPhNCFeatures natClass = AddGeneratedClass();
+ AddModifyMapping(AddAffixProcessRule(), natClass);
+ AddModifyMapping(AddAffixProcessRule(), natClass);
+
+ Assert.That(IsUserDefined(natClass), Is.True);
+ }
+
+ ///
+ /// A member of a multi-item environment is owned by the phonological data, so it
+ /// reaches its rule only through the sequence context that rule owns.
+ ///
+ [Test]
+ public void AutoNamedClassReachedThroughSequenceContext_IsUserDefined()
+ {
+ IPhNCFeatures natClass = AddGeneratedClass();
+ AddRuleOwnedSequenceContext(AddRegularRule("rule A"),
+ AddStandaloneNCContext(natClass));
+ AddStrucDescNCContext(AddRegularRule("rule B"), natClass);
+
+ Assert.That(IsUserDefined(natClass), Is.True);
+ }
+
+ [Test]
+ public void AutoNamedClassReachedThroughIterationInSequence_IsUserDefined()
+ {
+ IPhNCFeatures natClass = AddGeneratedClass();
+ IPhIterationContext iteration = AddIterationContext(AddStandaloneNCContext(natClass));
+ AddRuleOwnedSequenceContext(AddRegularRule("rule A"), iteration);
+ AddStrucDescNCContext(AddRegularRule("rule B"), natClass);
+
+ Assert.That(IsUserDefined(natClass), Is.True);
+ }
+
+ ///
+ /// One context object refers to the class, yet two rules use that context.
+ ///
+ [Test]
+ public void ContextSharedBySequencesInTwoRules_IsUserDefined()
+ {
+ IPhNCFeatures natClass = AddGeneratedClass();
+ IPhSimpleContextNC sharedCtxt = AddStandaloneNCContext(natClass);
+ AddRuleOwnedSequenceContext(AddRegularRule("rule A"), sharedCtxt);
+ AddRuleOwnedSequenceContext(AddRegularRule("rule B"), sharedCtxt);
+
+ Assert.That(IsUserDefined(natClass), Is.True);
+ }
+
+ [Test]
+ public void AutoNamedClassUsedByOneRule_IsNotUserDefined()
+ {
+ IPhNCFeatures natClass = AddGeneratedClass();
+ AddStrucDescNCContext(AddRegularRule("rule A"), natClass);
+
+ Assert.That(IsUserDefined(natClass), Is.False);
+ }
+
+ [Test]
+ public void AutoNamedClassUsedTwiceInOneRule_IsNotUserDefined()
+ {
+ IPhNCFeatures natClass = AddGeneratedClass();
+ IPhSegRuleRHS rhs = AddRegularRule("rule A");
+ AddStrucDescNCContext(rhs, natClass);
+ AddStrucChangeNCContext(rhs, natClass);
+
+ Assert.That(IsUserDefined(natClass), Is.False);
+ }
+
+ [Test]
+ public void AutoNamedClassInAffixInputAndMappingOfOneRule_IsNotUserDefined()
+ {
+ IPhNCFeatures natClass = AddGeneratedClass();
+ IMoAffixProcess rule = AddAffixProcessRule();
+ AddInputNCContext(rule, natClass);
+ AddModifyMapping(rule, natClass);
+
+ Assert.That(IsUserDefined(natClass), Is.False);
+ }
+
+ ///
+ /// The environment is attached to an allomorph so that a search past the environment
+ /// would have further objects to reach.
+ ///
+ [Test]
+ public void AutoNamedClassUsedByOneRuleAndAnEnvironment_IsNotUserDefined()
+ {
+ IPhNCFeatures natClass = AddGeneratedClass();
+ AddStrucDescNCContext(AddRegularRule("rule A"), natClass);
+ AddEnvironmentUsedByAnAllomorph(AddStandaloneNCContext(natClass));
+
+ Assert.That(IsUserDefined(natClass), Is.False);
+ }
+
+ [Test]
+ public void AutoNamedClassUsedByOneRuleAndALeftoverContext_IsNotUserDefined()
+ {
+ IPhNCFeatures natClass = AddGeneratedClass();
+ AddStrucDescNCContext(AddRegularRule("rule A"), natClass);
+ AddStandaloneNCContext(natClass);
+
+ Assert.That(IsUserDefined(natClass), Is.False);
+ }
+
+ [Test]
+ public void SharedClassBecomesAutoGeneratedWhenOneRuleIsDeleted()
+ {
+ IPhNCFeatures natClass = AddGeneratedClass();
+ AddStrucDescNCContext(AddRegularRule("rule A"), natClass);
+ IPhSegRuleRHS rhsB = AddRegularRule("rule B");
+ AddStrucDescNCContext(rhsB, natClass);
+ Assert.That(IsUserDefined(natClass), Is.True, "two rules share the class");
+
+ Cache.LanguageProject.PhonologicalDataOA.PhonRulesOS.Remove(rhsB.OwningRule);
+
+ Assert.That(IsUserDefined(natClass), Is.False);
+ }
+ }
+}
diff --git a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcNaturalClassDependencyTests.cs b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcNaturalClassDependencyTests.cs
index c8f2f59105..39ae080cfc 100644
--- a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcNaturalClassDependencyTests.cs
+++ b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcNaturalClassDependencyTests.cs
@@ -40,7 +40,7 @@ public void RegularRule_RegistersTheNameAndAbbreviationOfItsClass()
[Test]
public void RegularRule_RegistersTheNameOfARuleNamedClass()
{
- IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
+ IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" } });
IPhSegRuleRHS rhs = AddRegularRule("s to n");
AddStrucDescNCContext(rhs, natClass);
diff --git a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcNaturalClassDisplayTests.cs b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcNaturalClassDisplayTests.cs
index d4f6469427..e831a3f8e2 100644
--- a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcNaturalClassDisplayTests.cs
+++ b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcNaturalClassDisplayTests.cs
@@ -67,7 +67,7 @@ public void NamedClass_WithEmptyAbbreviation_DrawsStars()
[Test]
public void RuleNamedClass_WithOneFeature_DrawsFeature()
{
- IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
+ IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" } });
Assert.That(DrawContext(AddStandaloneNCContext(natClass)), Is.EqualTo("[+ vd]"));
@@ -76,7 +76,7 @@ public void RuleNamedClass_WithOneFeature_DrawsFeature()
[Test]
public void RuleNamedClass_WithThreeFeatures_DrawsEveryFeatureInAPile()
{
- IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
+ IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" }, { "cons", "+" }, { "cont", "-" } });
string drawn = DrawContext(AddStandaloneNCContext(natClass));
@@ -92,7 +92,7 @@ public void RuleNamedClass_WithThreeFeatures_DrawsEveryFeatureInAPile()
[Test]
public void RuleNamedClass_WithNoFeatures_DrawsQuestions()
{
- IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n", new FeatVals());
+ IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n", new FeatVals());
Assert.That(DrawContext(AddStandaloneNCContext(natClass)), Is.EqualTo("[???]"));
}
@@ -105,7 +105,7 @@ public void RuleNamedClass_WithNoFeatures_DrawsQuestions()
[Test]
public void RuleNamedClass_AbbreviatedC_DrawsFeature()
{
- IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
+ IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "cons", "+" } });
natClass.Abbreviation.SetAnalysisDefaultWritingSystem("C");
@@ -119,7 +119,7 @@ public void RuleNamedClass_AbbreviatedC_DrawsFeature()
[Test]
public void RuleNamedClass_OnceNamed_DrawsAbbreviation()
{
- IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
+ IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" } });
IPhSimpleContextNC ctxt = AddStandaloneNCContext(natClass);
Assert.That(DrawContext(ctxt), Is.EqualTo("[+ vd]"), "before the class is named");
diff --git a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcNaturalClassSizingTests.cs b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcNaturalClassSizingTests.cs
index 991e79e2e4..8d5c23287e 100644
--- a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcNaturalClassSizingTests.cs
+++ b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcNaturalClassSizingTests.cs
@@ -52,7 +52,7 @@ public void NamedClass_WithNoAbbreviation_IsSizedToTheStars()
[Test]
public void RuleNamedClass_WithOneFeature_IsSizedToItsFeature()
{
- IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
+ IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" } });
AssertCellMatchesDrawing(AddStandaloneNCContext(natClass), "[+ vd]");
@@ -70,7 +70,7 @@ public void NamedClass_WithThreeFeatures_OccupiesOneLine()
[Test]
public void RuleNamedClass_WithThreeFeatures_OccupiesOneLinePerFeature()
{
- IPhNCFeatures natClass = AddRuleNamedFeatureNaturalClass("s to n",
+ IPhNCFeatures natClass = AddAutoGeneratedFeatureNaturalClass("s to n",
new FeatVals { { "vd", "+" }, { "cons", "+" }, { "cont", "-" } });
Assert.That(NumLinesFor(AddStandaloneNCContext(natClass)), Is.EqualTo(3));
diff --git a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcTestBase.cs b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcTestBase.cs
index 7ba2075b2c..54c8d2348c 100644
--- a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcTestBase.cs
+++ b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcTestBase.cs
@@ -93,10 +93,10 @@ protected IPhNCFeatures AddFeatureNaturalClass(string name, string abbreviation,
}
///
- /// Adds a feature-based natural class named the way one built for a rule is named, in the
- /// user writing system and with no abbreviation.
+ /// Adds a feature-based natural class named the way one auto-generated for a rule
+ /// is named, in the user writing system and with no abbreviation.
///
- protected IPhNCFeatures AddRuleNamedFeatureNaturalClass(string ruleName, FeatVals featVals)
+ protected IPhNCFeatures AddAutoGeneratedFeatureNaturalClass(string ruleName, FeatVals featVals)
{
IPhNCFeatures natClass = AddFeatureNaturalClass(null, null, featVals);
natClass.Name.SetUserWritingSystem(string.Format(MEStrings.ksRuleNCFeatsName, ruleName));
diff --git a/Src/LexText/Morphology/NaturalClassRecordList.cs b/Src/LexText/Morphology/NaturalClassRecordList.cs
new file mode 100644
index 0000000000..5e9f392552
--- /dev/null
+++ b/Src/LexText/Morphology/NaturalClassRecordList.cs
@@ -0,0 +1,32 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System.Collections.Generic;
+using System.Linq;
+using SIL.LCModel;
+
+namespace SIL.FieldWorks.XWorks.MorphologyEditor
+{
+ ///
+ /// Exclude all auto-generated feature-based natural classes from the NC record list.
+ ///
+ public class NaturalClassRecordList : RecordList
+ {
+ protected override IEnumerable GetObjectSet()
+ {
+ return ExcludeAutoGenerated(base.GetObjectSet(), m_cache);
+ }
+
+ ///
+ /// The given natural class HVOs, less the feature-based classes generated automatically
+ /// for a rule.
+ ///
+ internal static IEnumerable ExcludeAutoGenerated(IEnumerable hvos, LcmCache cache)
+ {
+ var repo = cache.ServiceLocator.GetInstance();
+ return hvos.Where(hvo =>
+ !RuleFormulaControl.IsAutoGeneratedNaturalClass(repo.GetObject(hvo) as IPhNaturalClass));
+ }
+ }
+}
diff --git a/Src/LexText/Morphology/RuleFormulaControl.cs b/Src/LexText/Morphology/RuleFormulaControl.cs
index ab19d20a68..076a3d91ac 100644
--- a/Src/LexText/Morphology/RuleFormulaControl.cs
+++ b/Src/LexText/Morphology/RuleFormulaControl.cs
@@ -613,7 +613,9 @@ private void m_insertionControl_Insert(object sender, InsertEventArgs e)
break;
case RuleInsertType.NaturalClass:
- IEnumerable natClasses = m_cache.LangProject.PhonologicalDataOA.NaturalClassesOS.OrderBy(natc => natc.ShortName);
+ // show all natural classes that are not auto generated
+ IEnumerable natClasses = m_cache.LangProject.PhonologicalDataOA.NaturalClassesOS.Where(natc => !IsAutoGeneratedNaturalClass(natc))
+ .OrderBy(natc => natc.ShortName);
ICmObject ncObj = DisplayChooser(MEStrings.ksRuleNCOpt, MEStrings.ksRuleNCChooserLink,
"naturalClassedit", "RuleNaturalClassFlatList", natClasses);
var nc = ncObj as IPhNaturalClass;
@@ -980,49 +982,137 @@ protected int GetIndexToRemove(ICmObject[] objs, SelectionHelper sel, bool forwa
return -1;
}
+
///
- /// Returns a bool representing whether the currently selected feature-based natural class simple context is user defined.
+ /// True when two or more distinct rules use .
+ ///
+ internal static bool IsUsedByMoreThanOneRule(IPhNaturalClass natClass)
+ {
+ var rules = new HashSet();
+ var visited = new HashSet();
+ foreach (ICmObject referrer in natClass.ReferringObjects)
+ {
+ CollectOwningRules(referrer, rules, visited);
+ if (rules.Count > 1)
+ return true;
+ }
+ return false;
+ }
+
+ private static void CollectOwningRules(ICmObject obj, HashSet rules,
+ HashSet visited)
+ {
+ // Skip this object if we've already seen it.
+ if (!visited.Add(obj))
+ return;
+
+ // IPhSegmentRule covers regular or metathesis rules; IMoAffixProcess covers APRs
+ ICmObject rule = (ICmObject)obj.OwnerOfClass()
+ ?? obj.OwnerOfClass();
+ if (rule != null)
+ {
+ rules.Add(rule);
+ return;
+ }
+
+ // If the object isn't a phonological context, any other referring objects are
+ // not rules (they may be environments, allomorphs, etc).
+ if (!(obj is IPhPhonContext))
+ return;
+
+ // If the object is a phonological context, we need to check the referrers of
+ // its referrers for other rules.
+ foreach (ICmObject referrer in obj.ReferringObjects)
+ CollectOwningRules(referrer, rules, visited);
+ }
+
+ ///
+ /// Returns a bool representing whether the currently selected feature-based
+ /// natural class simple context is user defined.
///
///
- /// A user-defined natural class has a name assigned by the user.
- /// Natural classes are auto-generated when a user assigns phonological features, and they receive auto-generated names of a specific form.
+ /// A user-defined natural class has either no name or a name assigned by the user.
+ /// Natural classes are auto-generated when a user assigns phonological features,
+ /// and they receive auto-generated names of a specific form.
///
///
- /// Returns false if the natural class is not feature based, or if it has no name or an auto-generated name.
- /// Returns true otherwise.
+ /// Returns false if the natural class is not feature based,
+ /// or if it has an auto-generated name.
+ /// Returns true otherwise (including if the class has no name).
///
- public bool IsFeatureBasedNCNameUserDefined()
+ public bool IsFeatureBasedNCUserDefined()
{
// If the class is not feature based, return false.
if (!IsFeatsNCContextCurrent){ return false; }
var ctxt = (IPhSimpleContextNC) CurrentContext;
- return IsFeatureBasedNCNameUserDefined((IPhNCFeatures)ctxt.FeatureStructureRA);
+ return IsFeatureBasedNCUserDefined((IPhNCFeatures)ctxt.FeatureStructureRA);
}
///
- /// Reports whether a feature-based natural class carries a name the user gave it,
- /// as opposed to no name or the name generated when phonological features are inserted
- /// directly into a rule. Returns false when is null.
+ /// Reports whether a feature-based natural class is user defined.
+ /// Returns false when the class's name is auto-generated or
+ /// is null. Returns true otherwise.
///
- public static bool IsFeatureBasedNCNameUserDefined(IPhNCFeatures natClass)
+ public static bool IsFeatureBasedNCUserDefined(IPhNCFeatures natClass)
{
if (natClass == null)
return false;
- // Get the auto-generated text that precedes the rulename. (e.g. extracting "Created automatically for rule "" from "Created automatically for rule "{0}"")
- // Note that the generated rulename following the stubtext is different for "regular" vs affix rules, but in all cases the auto-generated stub is the same.
- string autoGeneratedNameStub = (MEStrings.ksRuleNCFeatsName).Split('{')[0];
-
- // If the natural class name is not null or auto generated, then it is a user defined class name.
- // If a class has no name in any WS, it reads back from LCM as the placeholder "***" rather than as empty text,
- // so we need to manually check whether the name exists in any available WS to determine if the NC is named.
+ // If the NC name is not auto generated, then the class is user defined.
+ // A user-defined NC may have no name, an auto generated NC always has a
+ // generated name.
var name = natClass.Name;
bool isNamed = name != null && name.AvailableWritingSystemIds.Any(ws => !string.IsNullOrEmpty(name.get_String(ws).Text));
string ncName = isNamed ? name.UserDefaultWritingSystem?.Text : null;
- bool isAutoGeneratedOrNull = string.IsNullOrEmpty(ncName) || ncName.Contains(autoGeneratedNameStub);
- return !isAutoGeneratedOrNull;
+
+ // Get the name for an autogenerated natural class, localized in the
+ // user interface language.
+ // e.g. In English, the name would be "Created automatically for rule "{0}""
+ if (!IsAutoGeneratedName(ncName, MEStrings.ksRuleNCFeatsName))
+ return true;
+
+ // If a generated class is used by more than one rule, the user has chosen to link it
+ // to multiple rules, and it should be treated as user-defined.
+ return IsUsedByMoreThanOneRule(natClass);
+ }
+
+ ///
+ /// Reports whether is a name generated from
+ /// , the format string auto-generated natural classes are
+ /// named with (its rule name placeholder is "{0}"). Returns false for a null or
+ /// empty name, and for a format with no placeholder, since generated names can't
+ /// be recognized from it.
+ ///
+ internal static bool IsAutoGeneratedName(string ncName, string format)
+ {
+ if (string.IsNullOrEmpty(ncName))
+ return false;
+
+ // Different languages will put the rulename before or after the generated
+ // stubtext, so we need to check against both pre and post rulename stubs.
+ int ruleNameIndex = format.IndexOf("{0}", StringComparison.Ordinal);
+ if (ruleNameIndex < 0)
+ return false; // can't recognize generated names in this translation
+ string autoGenPreStub = format.Substring(0, ruleNameIndex);
+ string autoGenPostStub = format.Substring(ruleNameIndex + 3);
+ // The whole name must be the generated text around a rule name.
+ // (Otherwise, if the generated name has been altered, this is instead considered
+ // a user defined class.)
+ return ncName.Length >= autoGenPreStub.Length + autoGenPostStub.Length
+ && ncName.StartsWith(autoGenPreStub, StringComparison.Ordinal)
+ && ncName.EndsWith(autoGenPostStub, StringComparison.Ordinal);
}
+
+ ///
+ /// True for feature-based natural classes generated automatically for a rule.
+ /// Phoneme-based classes are never auto-generated.
+ ///
+ public static bool IsAutoGeneratedNaturalClass(IPhNaturalClass nc)
+ {
+ return nc is IPhNCFeatures featNC && !IsFeatureBasedNCUserDefined(featNC);
+ }
+
///
/// Sets the phonological features for the currently selected natural class simple context with
/// a feature-based natural class.
diff --git a/Src/LexText/Morphology/RuleFormulaSlice.cs b/Src/LexText/Morphology/RuleFormulaSlice.cs
index 0b1be0bb1b..3ad0e079ae 100644
--- a/Src/LexText/Morphology/RuleFormulaSlice.cs
+++ b/Src/LexText/Morphology/RuleFormulaSlice.cs
@@ -153,7 +153,7 @@ public bool OnDisplayContextSetFeatures(object commandObject, ref UIItemDisplayP
bool enable = false;
if (RuleFormulaControl.IsFeatsNCContextCurrent)
{
- bool isUserDefinedNC = RuleFormulaControl.IsFeatureBasedNCNameUserDefined();
+ bool isUserDefinedNC = RuleFormulaControl.IsFeatureBasedNCUserDefined();
enable = !isUserDefinedNC; //enable is true iff IsFeatsNCContextCurrent is true and IsNCNameUserDefined is false
}
@@ -173,6 +173,12 @@ public bool OnDisplayContextJumpToNaturalClass(object commandObject, ref UIItemD
{
CheckDisposed();
bool enable = RuleFormulaControl.IsNCContextCurrent;
+
+ // If the current context is a feature-based natural class,
+ // we only want to allow jumping to the natural class if it is user-defined.
+ if (RuleFormulaControl.IsFeatsNCContextCurrent)
+ enable = RuleFormulaControl.IsFeatureBasedNCUserDefined();
+
display.Enabled = enable;
display.Visible = enable;
return true;
diff --git a/Src/LexText/Morphology/RuleFormulaVcBase.cs b/Src/LexText/Morphology/RuleFormulaVcBase.cs
index 007c5be9f4..8b2030d269 100644
--- a/Src/LexText/Morphology/RuleFormulaVcBase.cs
+++ b/Src/LexText/Morphology/RuleFormulaVcBase.cs
@@ -135,7 +135,7 @@ public override void Display(IVwEnv vwenv, int hvo, int frag)
// User-defined feature-based natural classes should display
// only the abbreviation.
- if (RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass))
+ if (RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass))
{
if (!isOuterIterCtxt)
OpenSingleLinePile(vwenv, GetMaxNumLines());
@@ -561,7 +561,7 @@ protected int GetNumLines(IPhContextOrVar ctxtOrVar)
// User-defined, feature-based natural classes should display
// only the abbreviation, which is a single line.
- if (RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass))
+ if (RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass))
return 1;
// Otherwise, the list of features is displayed.
@@ -679,7 +679,7 @@ int GetNCCtxtWidth(IPhSimpleContextNC ctxt, IVwEnv vwenv)
// User-defined feature-based natural classes display only the abbreviation,
// so get width of the abbreviation.
var natClass = (IPhNCFeatures)ctxt.FeatureStructureRA;
- if (RuleFormulaControl.IsFeatureBasedNCNameUserDefined(natClass))
+ if (RuleFormulaControl.IsFeatureBasedNCUserDefined(natClass))
{
int len = GetStrWidth(ctxt.FeatureStructureRA.Abbreviation.BestAnalysisAlternative, null, vwenv);
len += GetStrWidth(m_leftBracket, null, vwenv);