diff --git a/Src/LexText/LexTextControls/PatternView.cs b/Src/LexText/LexTextControls/PatternView.cs
index 27bbb76eca..5337604c6b 100644
--- a/Src/LexText/LexTextControls/PatternView.cs
+++ b/Src/LexText/LexTextControls/PatternView.cs
@@ -69,6 +69,15 @@ protected override EditingHelper CreateEditingHelper()
return new PatternEditingHelper(Cache, this);
}
+ ///
+ /// Activate() is disabled by default in ReadOnlyViews, but a pattern editor does want to
+ /// show selections so the user can see what a chooser insert/delete will act on.
+ ///
+ protected override bool AllowDisplaySelection
+ {
+ get { return true; }
+ }
+
public void Init(Mediator mediator, PropertyTable propertyTable, int hvo, IPatternControl patternControl, PatternVcBase vc, int rootFrag, ISilDataAccess sda)
{
CheckDisposed();
diff --git a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaControlWiringTests.cs b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaControlWiringTests.cs
new file mode 100644
index 0000000000..abfecba4a7
--- /dev/null
+++ b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaControlWiringTests.cs
@@ -0,0 +1,46 @@
+// 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;
+
+namespace SIL.FieldWorks.XWorks.MorphologyEditor
+{
+ ///
+ /// Constructing each real rule formula control must produce a read-only rootsite, since a
+ /// rule cell is modifiable only by chooser-insert and delete.
+ ///
+ [TestFixture]
+ public class RuleFormulaControlWiringTests
+ {
+ [Test]
+ public void RegRuleFormulaControl_RootSiteIsReadOnly()
+ {
+ using (var control = new RegRuleFormulaControl(null))
+ {
+ Assert.That(control.RootSite.ReadOnlyView, Is.True,
+ "RegRuleFormulaControl must wire up a read-only rootsite");
+ }
+ }
+
+ [Test]
+ public void MetaRuleFormulaControl_RootSiteIsReadOnly()
+ {
+ using (var control = new MetaRuleFormulaControl(null))
+ {
+ Assert.That(control.RootSite.ReadOnlyView, Is.True,
+ "MetaRuleFormulaControl must wire up a read-only rootsite");
+ }
+ }
+
+ [Test]
+ public void AffixRuleFormulaControl_RootSiteIsReadOnly()
+ {
+ using (var control = new AffixRuleFormulaControl(null))
+ {
+ Assert.That(control.RootSite.ReadOnlyView, Is.True,
+ "AffixRuleFormulaControl must wire up a read-only rootsite");
+ }
+ }
+ }
+}
diff --git a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaDirectEditReproTests.cs b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaDirectEditReproTests.cs
new file mode 100644
index 0000000000..a3ffc56c84
--- /dev/null
+++ b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaDirectEditReproTests.cs
@@ -0,0 +1,279 @@
+// 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.Windows.Forms;
+using NUnit.Framework;
+using SIL.LCModel;
+using SIL.LCModel.Core.Text;
+using SIL.LCModel.Core.KernelInterfaces;
+using SIL.LCModel.Infrastructure;
+using SIL.FieldWorks.Common.RootSites;
+using SIL.FieldWorks.Common.ViewsInterfaces;
+using SIL.FieldWorks.LexText.Controls;
+using XCore;
+
+namespace SIL.FieldWorks.XWorks.MorphologyEditor
+{
+ ///
+ /// Drives a real IVwRootBox (the managed Views engine), hosted by a live
+ /// PatternView/RegRuleFormulaVc pair against a real in-memory LcmCache, and calls
+ /// IVwSelection.ReplaceWithTsString directly -- the same low-level entry point IME
+ /// composition or drag-and-drop would use, and one PatternView.OnKeyPress never sees
+ /// because it only reacts to Windows key events.
+ ///
+ [TestFixture]
+ public class RuleFormulaDirectEditReproTests : MemoryOnlyBackendProviderTestBase
+ {
+ private Mediator m_mediator;
+ private PropertyTable m_propertyTable;
+ private TestPatternView m_view;
+
+ public override void TestSetup()
+ {
+ base.TestSetup();
+ m_mediator = new Mediator();
+ m_propertyTable = new PropertyTable(m_mediator);
+ m_propertyTable.SetProperty("cache", Cache, false);
+ }
+
+ public override void TestTearDown()
+ {
+ if (m_view != null)
+ {
+ m_view.Dispose();
+ m_view = null;
+ }
+ if (m_propertyTable != null)
+ {
+ m_propertyTable.Dispose();
+ m_propertyTable = null;
+ }
+ if (m_mediator != null)
+ {
+ m_mediator.Dispose();
+ m_mediator = null;
+ }
+ base.TestTearDown();
+ }
+
+ /// Minimal no-op IPatternControl -- sufficient because we never drive
+ /// selection through the chooser/insert/delete UI in this test; we only need
+ /// PatternView's selection-changed handler not to crash when we install a
+ /// selection directly.
+ private class NullPatternControl : IPatternControl
+ {
+ public object GetContext(SelectionHelper sel) => null;
+ public object GetContext(SelectionHelper sel, SelectionHelper.SelLimitType limit) => null;
+ public object GetItem(SelectionHelper sel, SelectionHelper.SelLimitType limit) => null;
+ public int GetItemContextIndex(object ctxt, object obj) => -1;
+ public SelLevInfo[] GetLevelInfo(object ctxt, int index) => null;
+ public int GetContextCount(object ctxt) => 0;
+ public object GetNextContext(object ctxt) => null;
+ public object GetPrevContext(object ctxt) => null;
+ public int GetFlid(object ctxt) => 0;
+ }
+
+ /// Exposes the protected layout hook so the view can be laid out
+ /// headlessly.
+ private class TestPatternView : PatternView
+ {
+ public void CallLayout()
+ {
+ OnLayout(new LayoutEventArgs(this, string.Empty));
+ }
+
+ public void SimulateKeyDown(Keys key)
+ {
+ var e = new KeyEventArgs(key);
+ OnKeyDown(e);
+ }
+
+ public bool TestAllowDisplaySelection => AllowDisplaySelection;
+ }
+
+ private IPhPhoneme CreatePhoneme(string name)
+ {
+ IPhPhoneme p = null;
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ Cache.LangProject.PhonologicalDataOA.PhonemeSetsOS.Add(
+ Cache.ServiceLocator.GetInstance().Create());
+ p = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LangProject.PhonologicalDataOA.PhonemeSetsOS[0].PhonemesOC.Add(p);
+ p.Name.SetVernacularDefaultWritingSystem(name);
+ });
+ return p;
+ }
+
+ ///
+ /// Builds a real regular-rule RHS whose left context is a single phoneme, hosts it in a
+ /// live PatternView/RegRuleFormulaVc pair, and returns the phoneme plus the live view.
+ ///
+ private (IPhPhoneme phoneme, TestPatternView view) BuildLiveRuleFormulaView(string phonemeName)
+ {
+ IPhPhoneme phoneme = CreatePhoneme(phonemeName);
+ IPhSegRuleRHS rhs = null;
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ var rule = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LangProject.PhonologicalDataOA.PhonRulesOS.Add(rule);
+ rhs = Cache.ServiceLocator.GetInstance().Create();
+ rule.RightHandSidesOS.Add(rhs);
+ var segCtxt = Cache.ServiceLocator.GetInstance().Create();
+ rhs.LeftContextOA = segCtxt;
+ segCtxt.FeatureStructureRA = phoneme;
+ });
+
+ var vc = new RegRuleFormulaVc(Cache, m_propertyTable);
+ var view = new TestPatternView { Cache = Cache, Visible = false, Width = 300, Height = 60 };
+ view.Init(m_mediator, m_propertyTable, rhs.Hvo, new NullPatternControl(), vc, RegRuleFormulaVc.kfragRHS,
+ Cache.MainCacheAccessor);
+ view.ReadOnlyView = true;
+ view.CallLayout();
+ m_view = view;
+ return (phoneme, view);
+ }
+
+ ///
+ /// Builds a real regular-rule RHS whose left context is a natural class special-cased to
+ /// display only its abbreviation ("C" or "V"),
+ /// hosts it in a live PatternView/RegRuleFormulaVc pair, and returns the natural class
+ /// plus the live view.
+ ///
+ private (IPhNaturalClass naturalClass, TestPatternView view) BuildLiveRuleFormulaViewWithNaturalClass(string abbr)
+ {
+ IPhNaturalClass nc = null;
+ IPhSegRuleRHS rhs = null;
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ nc = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LangProject.PhonologicalDataOA.NaturalClassesOS.Add(nc);
+ nc.Name.SetAnalysisDefaultWritingSystem("Test Class");
+ nc.Abbreviation.SetAnalysisDefaultWritingSystem(abbr);
+
+ var rule = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LangProject.PhonologicalDataOA.PhonRulesOS.Add(rule);
+ rhs = Cache.ServiceLocator.GetInstance().Create();
+ rule.RightHandSidesOS.Add(rhs);
+ var ncCtxt = Cache.ServiceLocator.GetInstance().Create();
+ rhs.LeftContextOA = ncCtxt;
+ ncCtxt.FeatureStructureRA = nc;
+ // GetNumLines(ncCtxt) must be exactly 1 to hit the "C"/"V" abbreviation-only
+ // branch; one plus-constraint variable is the cheapest way to make that so.
+ var constraint = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LangProject.PhonologicalDataOA.FeatConstraintsOS.Add(constraint);
+ ncCtxt.PlusConstrRS.Add(constraint);
+ });
+
+ var vc = new RegRuleFormulaVc(Cache, m_propertyTable);
+ var view = new TestPatternView { Cache = Cache, Visible = false, Width = 300, Height = 60 };
+ view.Init(m_mediator, m_propertyTable, rhs.Hvo, new NullPatternControl(), vc, RegRuleFormulaVc.kfragRHS,
+ Cache.MainCacheAccessor);
+ view.ReadOnlyView = true;
+ view.CallLayout();
+ m_view = view;
+ return (nc, view);
+ }
+
+ ///
+ /// Selects the whole displayed natural-class abbreviation (via its object path from the
+ /// RHS root, bypassing PatternView.OnKeyPress entirely) and replaces its text directly
+ /// through IVwSelection.ReplaceWithTsString. A natural class is shared by every rule that
+ /// references it, so an edit landing here is a project-wide rename, exactly like the
+ /// phoneme case.
+ ///
+ [Test]
+ public void ReplaceWithTsString_OnNaturalClassAbbreviation_BypassesOnKeyPress_AndShouldNotRenameTheClass()
+ {
+ var (naturalClass, view) = BuildLiveRuleFormulaViewWithNaturalClass("C");
+
+ var levels = new[]
+ {
+ new SelLevInfo { tag = PhSimpleContextNCTags.kflidFeatureStructure, ihvo = 0 },
+ new SelLevInfo { tag = PhSegRuleRHSTags.kflidLeftContext, ihvo = 0 }
+ };
+ IVwSelection sel = view.RootBox.MakeTextSelInObj(0, levels.Length, levels, 0, null,
+ true, false, false, /* fWholeObj */ true, /* fInstall */ true);
+ Assert.That(sel, Is.Not.Null,
+ "could not construct a selection over the natural class's abbreviation display -- fixture/path assumption is wrong");
+
+ ITsString corrupted = TsStringUtils.MakeString("CORRUPTED", Cache.DefaultAnalWs);
+
+ UndoableUnitOfWorkHelper.Do("undo", "redo", naturalClass, () => sel.ReplaceWithTsString(corrupted));
+
+ string abbrAfter = naturalClass.Abbreviation.AnalysisDefaultWritingSystem.Text;
+ Assert.That(abbrAfter, Is.EqualTo("C"),
+ "an edit that bypassed PatternView.OnKeyPress altered the real, project-wide " +
+ "PhNaturalClass.Abbreviation (got '" + abbrAfter + "')");
+ }
+
+ ///
+ /// Selects the whole displayed phoneme (via its object path from the RHS root, bypassing
+ /// any WM_CHAR-level filtering entirely -- PatternView.OnKeyPress is never invoked here)
+ /// and replaces its text directly through IVwSelection.ReplaceWithTsString, exactly the
+ /// kind of call an IME composition commit or a drag-and-drop would make.
+ ///
+ [Test]
+ public void ReplaceWithTsString_OnPhonemeTerminalUnit_BypassesOnKeyPress_AndShouldNotRenameThePhoneme()
+ {
+ var (phoneme, view) = BuildLiveRuleFormulaView("p");
+
+ var levels = new[]
+ {
+ new SelLevInfo { tag = PhSimpleContextSegTags.kflidFeatureStructure, ihvo = 0 },
+ new SelLevInfo { tag = PhSegRuleRHSTags.kflidLeftContext, ihvo = 0 }
+ };
+ IVwSelection sel = view.RootBox.MakeTextSelInObj(0, levels.Length, levels, 0, null,
+ true, false, false, /* fWholeObj */ true, /* fInstall */ true);
+ Assert.That(sel, Is.Not.Null,
+ "could not construct a selection over the phoneme's terminal-unit display -- fixture/path assumption is wrong");
+
+ ITsString corrupted = TsStringUtils.MakeString("CORRUPTED", Cache.DefaultVernWs);
+
+ // The rootsite's own low-level text-replacement API, which bypasses the WM_CHAR
+ // filter. The unit of work is required for any edit to commit, not part of the
+ // bypass.
+ UndoableUnitOfWorkHelper.Do("undo", "redo", phoneme, () => sel.ReplaceWithTsString(corrupted));
+
+ string nameAfter = phoneme.Name.VernacularDefaultWritingSystem.Text;
+ Assert.That(nameAfter, Is.EqualTo("p"),
+ "an edit that bypassed PatternView.OnKeyPress altered the real, project-wide " +
+ "PhPhoneme.Name (got '" + nameAfter + "')");
+ }
+
+ ///
+ /// A read-only rootsite must not prevent PatternView's own Delete-key handling, which
+ /// removes items through RemoveItemsRequested rather than by editing text.
+ ///
+ [Test]
+ public void DeleteKey_StillRaisesRemoveItemsRequested_WhenRootsiteIsReadOnly()
+ {
+ var (_, view) = BuildLiveRuleFormulaView("p");
+ Assert.That(view.ReadOnlyView, Is.True, "fixture assumption: the rootsite is read-only");
+
+ bool removeRequested = false;
+ view.RemoveItemsRequested += (sender, e) => removeRequested = true;
+
+ view.SimulateKeyDown(Keys.Delete);
+
+ Assert.That(removeRequested, Is.True,
+ "Delete must still raise RemoveItemsRequested when the rootsite is read-only");
+ }
+
+ ///
+ /// A read-only rootsite suppresses Activate() by default
+ /// (SimpleRootSite.AllowDisplaySelection),
+ /// which would hide the selection a chooser insert/delete needs the user to see.
+ ///
+ [Test]
+ public void AllowDisplaySelection_IsTrue_WhenRootsiteIsReadOnly()
+ {
+ var (_, view) = BuildLiveRuleFormulaView("p");
+ Assert.That(view.ReadOnlyView, Is.True, "fixture assumption: the rootsite is read-only");
+
+ Assert.That(view.TestAllowDisplaySelection, Is.True,
+ "the selection must still be shown even though the rootsite is read-only");
+ }
+ }
+}
diff --git a/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcBaseEditabilityTests.cs b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcBaseEditabilityTests.cs
new file mode 100644
index 0000000000..79a381efa1
--- /dev/null
+++ b/Src/LexText/Morphology/MorphologyEditorDllTests/RuleFormulaVcBaseEditabilityTests.cs
@@ -0,0 +1,349 @@
+// 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.Runtime.InteropServices;
+using NUnit.Framework;
+using SIL.LCModel;
+using SIL.LCModel.Core.KernelInterfaces;
+using SIL.LCModel.Infrastructure;
+using SIL.FieldWorks.Common.ViewsInterfaces;
+using XCore;
+
+namespace SIL.FieldWorks.XWorks.MorphologyEditor
+{
+ ///
+ /// The rule formula view is modifiable only by chooser-insert and delete, so
+ /// RuleFormulaVcBase must mark the natural-class abbreviation and terminal-unit
+ /// (phoneme/boundary) name fragments non-editable; both bind directly to the
+ /// referenced object's own live string field.
+ ///
+ [TestFixture]
+ public class RuleFormulaVcBaseEditabilityTests : MemoryOnlyBackendProviderTestBase
+ {
+ private Mediator m_mediator;
+ private PropertyTable m_propertyTable;
+
+ public override void TestSetup()
+ {
+ base.TestSetup();
+ m_mediator = new Mediator();
+ m_propertyTable = new PropertyTable(m_mediator);
+ }
+
+ public override void TestTearDown()
+ {
+ if (m_propertyTable != null)
+ {
+ m_propertyTable.Dispose();
+ m_propertyTable = null;
+ }
+ if (m_mediator != null)
+ {
+ m_mediator.Dispose();
+ m_mediator = null;
+ }
+ base.TestTearDown();
+ }
+
+ private IPhNaturalClass CreateNaturalClass(string abbr)
+ {
+ IPhNaturalClass nc = null;
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ nc = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LangProject.PhonologicalDataOA.NaturalClassesOS.Add(nc);
+ nc.Name.SetAnalysisDefaultWritingSystem("Test Class");
+ nc.Abbreviation.SetAnalysisDefaultWritingSystem(abbr);
+ });
+ return nc;
+ }
+
+ private IPhPhoneme CreatePhoneme(string name)
+ {
+ IPhPhoneme p = null;
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ Cache.LangProject.PhonologicalDataOA.PhonemeSetsOS.Add(
+ Cache.ServiceLocator.GetInstance().Create());
+ p = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LangProject.PhonologicalDataOA.PhonemeSetsOS[0].PhonemesOC.Add(p);
+ p.Name.SetVernacularDefaultWritingSystem(name);
+ });
+ return p;
+ }
+
+ ///
+ /// The natural-class abbreviation fragment (kfragNC) is rendered via AddStringAltMember
+ /// directly against the natural class's own Abbreviation field.
+ ///
+ [Test]
+ public void Display_NaturalClassAbbreviationFragment_IsMarkedNotEditable()
+ {
+ IPhNaturalClass nc = CreateNaturalClass("Stp");
+ var vc = new RegRuleFormulaVc(Cache, m_propertyTable);
+ var env = new EditabilityRecordingEnv();
+
+ vc.Display(env, nc.Hvo, RuleFormulaVcBase.kfragNC);
+
+ Assert.That(env.StringAltMemberCalls, Is.Not.Empty,
+ "expected AddStringAltMember to be called for the NC abbreviation fragment");
+ foreach (var call in env.StringAltMemberCalls)
+ {
+ Assert.That(call.EditableAtCallTime, Is.EqualTo((int)TptEditable.ktptNotEditable),
+ "the natural class abbreviation must not be directly editable in the rule formula view");
+ }
+ }
+
+ ///
+ /// The terminal-unit (phoneme/boundary) fragment (kfragTerminalUnit) is rendered via
+ /// AddStringAltMember directly against the terminal unit's own Name field -- a live
+ /// write channel into the phoneme's real, project-wide name.
+ ///
+ [Test]
+ public void Display_TerminalUnitNameFragment_IsMarkedNotEditable()
+ {
+ IPhPhoneme phoneme = CreatePhoneme("p");
+ var vc = new RegRuleFormulaVc(Cache, m_propertyTable);
+ var env = new EditabilityRecordingEnv();
+
+ vc.Display(env, phoneme.Hvo, RuleFormulaVcBase.kfragTerminalUnit);
+
+ Assert.That(env.StringAltMemberCalls, Is.Not.Empty,
+ "expected AddStringAltMember to be called for the terminal unit name fragment");
+ foreach (var call in env.StringAltMemberCalls)
+ {
+ Assert.That(call.EditableAtCallTime, Is.EqualTo((int)TptEditable.ktptNotEditable),
+ "the phoneme/boundary name must not be directly editable in the rule formula view");
+ }
+ }
+
+ /// Same defect, exercised through the metathesis-rule view
+ /// constructor.
+ [Test]
+ public void Display_TerminalUnitNameFragment_ViaMetaRuleFormulaVc_IsMarkedNotEditable()
+ {
+ IPhPhoneme phoneme = CreatePhoneme("t");
+ var vc = new MetaRuleFormulaVc(Cache, m_propertyTable);
+ var env = new EditabilityRecordingEnv();
+
+ vc.Display(env, phoneme.Hvo, RuleFormulaVcBase.kfragTerminalUnit);
+
+ Assert.That(env.StringAltMemberCalls, Is.Not.Empty);
+ foreach (var call in env.StringAltMemberCalls)
+ {
+ Assert.That(call.EditableAtCallTime, Is.EqualTo((int)TptEditable.ktptNotEditable),
+ "metathesis rule formula view shares the same defect as the base class");
+ }
+ }
+
+ /// Same defect, exercised through the affix-process view constructor.
+ [Test]
+ public void Display_TerminalUnitNameFragment_ViaAffixRuleFormulaVc_IsMarkedNotEditable()
+ {
+ IPhPhoneme phoneme = CreatePhoneme("k");
+ var vc = new AffixRuleFormulaVc(Cache, m_propertyTable);
+ var env = new EditabilityRecordingEnv();
+
+ vc.Display(env, phoneme.Hvo, RuleFormulaVcBase.kfragTerminalUnit);
+
+ Assert.That(env.StringAltMemberCalls, Is.Not.Empty);
+ foreach (var call in env.StringAltMemberCalls)
+ {
+ Assert.That(call.EditableAtCallTime, Is.EqualTo((int)TptEditable.ktptNotEditable),
+ "affix process rule formula view shares the same defect as the base class");
+ }
+ }
+
+ ///
+ /// The feature-value line (kfragFeature) is a computed "abbreviation value" string bound
+ /// to a fake tag, not free text.
+ ///
+ [Test]
+ public void Display_FeatureLineFragment_IsMarkedNotEditable()
+ {
+ var vc = new RegRuleFormulaVc(Cache, m_propertyTable);
+ var env = new EditabilityRecordingEnv();
+
+ vc.Display(env, 0, RuleFormulaVcBase.kfragFeature);
+
+ Assert.That(env.AddPropCalls, Is.Not.Empty,
+ "expected AddProp to be called for the feature-line fragment");
+ foreach (var call in env.AddPropCalls)
+ {
+ Assert.That(call.EditableAtCallTime, Is.EqualTo((int)TptEditable.ktptNotEditable),
+ "the feature-value line must not be directly editable in the rule formula view");
+ }
+ }
+
+ /// The plus-variable line (kfragPlusVariable) is a computed string bound to a
+ /// fake tag, not free text.
+ [Test]
+ public void Display_PlusVariableLineFragment_IsMarkedNotEditable()
+ {
+ var vc = new RegRuleFormulaVc(Cache, m_propertyTable);
+ var env = new EditabilityRecordingEnv();
+
+ vc.Display(env, 0, RuleFormulaVcBase.kfragPlusVariable);
+
+ Assert.That(env.AddPropCalls, Is.Not.Empty,
+ "expected AddProp to be called for the plus-variable fragment");
+ foreach (var call in env.AddPropCalls)
+ {
+ Assert.That(call.EditableAtCallTime, Is.EqualTo((int)TptEditable.ktptNotEditable),
+ "the plus-variable line must not be directly editable in the rule formula view");
+ }
+ }
+
+ /// The minus-variable line (kfragMinusVariable) is a computed string bound to a
+ /// fake tag, not free text.
+ [Test]
+ public void Display_MinusVariableLineFragment_IsMarkedNotEditable()
+ {
+ var vc = new RegRuleFormulaVc(Cache, m_propertyTable);
+ var env = new EditabilityRecordingEnv();
+
+ vc.Display(env, 0, RuleFormulaVcBase.kfragMinusVariable);
+
+ Assert.That(env.AddPropCalls, Is.Not.Empty,
+ "expected AddProp to be called for the minus-variable fragment");
+ foreach (var call in env.AddPropCalls)
+ {
+ Assert.That(call.EditableAtCallTime, Is.EqualTo((int)TptEditable.ktptNotEditable),
+ "the minus-variable line must not be directly editable in the rule formula view");
+ }
+ }
+
+ ///
+ /// Records enough of IVwEnv's calls to observe, at the moment AddStringAltMember binds a
+ /// fragment to a real domain-object field, whether the view constructor had most recently
+ /// set the ktptEditable property to NotEditable. All other members are unused by the
+ /// fragments under test and throw if hit, so a future change that routes through a
+ /// different IVwEnv member will fail loudly rather than silently pass.
+ ///
+ private class EditabilityRecordingEnv : IVwEnv
+ {
+ public struct Call
+ {
+ public int Tag;
+ public int Ws;
+ public int EditableAtCallTime;
+ }
+
+ public struct PropCall
+ {
+ public int Tag;
+ public int Frag;
+ public int EditableAtCallTime;
+ }
+
+ public List StringAltMemberCalls = new List();
+ public List AddPropCalls = new List();
+
+ private int m_currentEditable = int.MinValue; // sentinel: never set
+
+ public void AddStringAltMember(int tag, int ws, IVwViewConstructor _vwvc)
+ {
+ StringAltMemberCalls.Add(new Call { Tag = tag, Ws = ws, EditableAtCallTime = m_currentEditable });
+ }
+
+ public void AddProp(int tag, IVwViewConstructor _vwvc, int frag)
+ {
+ AddPropCalls.Add(new PropCall { Tag = tag, Frag = frag, EditableAtCallTime = m_currentEditable });
+ }
+
+ public void set_IntProperty(int tpt, int tpv, int nValue)
+ {
+ if (tpt == (int)FwTextPropType.ktptEditable)
+ m_currentEditable = nValue;
+ }
+
+ public ITsTextProps Props
+ {
+ set { /* not relevant to editability of these two fragments */ }
+ }
+
+ public void get_StringWidth(ITsString _tss, ITsTextProps _ttp, out int dmpx, out int dmpy)
+ {
+ dmpx = 0;
+ dmpy = 0;
+ }
+
+ public int OpenObject
+ {
+ get { throw new NotImplementedException(); }
+ }
+
+ public int EmbeddingLevel
+ {
+ get { return 0; }
+ }
+
+ public ISilDataAccess DataAccess
+ {
+ get { throw new NotImplementedException(); }
+ }
+
+ public void AddObjProp(int tag, IVwViewConstructor _vwvc, int frag) { throw new NotImplementedException(); }
+ public void AddObjVec(int tag, IVwViewConstructor _vwvc, int frag) { throw new NotImplementedException(); }
+ public void AddObjVecItems(int tag, IVwViewConstructor _vwvc, int frag) { throw new NotImplementedException(); }
+ public void AddReversedObjVecItems(int tag, IVwViewConstructor _vwvc, int frag) { throw new NotImplementedException(); }
+ public void AddObj(int hvo, IVwViewConstructor _vwvc, int frag) { throw new NotImplementedException(); }
+ public void AddLazyVecItems(int tag, IVwViewConstructor _vwvc, int frag) { throw new NotImplementedException(); }
+ public void AddLazyItems(int[] _rghvo, int chvo, IVwViewConstructor _vwvc, int frag) { throw new NotImplementedException(); }
+ public void AddDerivedProp(int[] _rgtag, int ctag, IVwViewConstructor _vwvc, int frag) { throw new NotImplementedException(); }
+ public void NoteDependency(int[] _rghvo, int[] _rgtag, int chvo) { }
+ public void NoteStringValDependency(int hvo, int tag, int ws, ITsString _tssVal) { throw new NotImplementedException(); }
+ public void AddStringProp(int tag, IVwViewConstructor _vwvc) { throw new NotImplementedException(); }
+ public void AddUnicodeProp(int tag, int ws, IVwViewConstructor _vwvc) { throw new NotImplementedException(); }
+ public void AddIntProp(int tag) { throw new NotImplementedException(); }
+ public void AddIntPropPic(int tag, IVwViewConstructor _vc, int frag, int nMin, int nMax) { throw new NotImplementedException(); }
+ public void AddStringAlt(int tag) { throw new NotImplementedException(); }
+ public void AddStringAltSeq(int tag, int[] _rgenc, int cws) { throw new NotImplementedException(); }
+ public void AddString(ITsString _ss) { throw new NotImplementedException(); }
+ public void AddTimeProp(int tag, uint flags) { throw new NotImplementedException(); }
+ public int CurrentObject() { throw new NotImplementedException(); }
+ public void GetOuterObject(int ichvoLevel, out int _hvo, out int _tag, out int _ihvo) { throw new NotImplementedException(); }
+ public void AddWindow(IVwEmbeddedWindow _ew, int dmpAscent, bool fJustifyRight, bool fAutoShow) { throw new NotImplementedException(); }
+ public void AddSeparatorBar() { throw new NotImplementedException(); }
+ public void AddSimpleRect(int rgb, int dmpWidth, int dmpHeight, int dmpBaselineOffset) { throw new NotImplementedException(); }
+ public void OpenDiv() { throw new NotImplementedException(); }
+ public void CloseDiv() { throw new NotImplementedException(); }
+ public void OpenParagraph() { throw new NotImplementedException(); }
+ public void OpenTaggedPara() { throw new NotImplementedException(); }
+ public void OpenMappedPara() { throw new NotImplementedException(); }
+ public void OpenMappedTaggedPara() { throw new NotImplementedException(); }
+ public void OpenConcPara(int ichMinItem, int ichLimItem, VwConcParaOpts cpoFlags, int dmpAlign) { throw new NotImplementedException(); }
+ public void OpenOverridePara(int cOverrideProperties, DispPropOverride[] _rgOverrideProperties) { throw new NotImplementedException(); }
+ public void CloseParagraph() { throw new NotImplementedException(); }
+ public void OpenInnerPile() { throw new NotImplementedException(); }
+ public void CloseInnerPile() { throw new NotImplementedException(); }
+ public void OpenSpan() { throw new NotImplementedException(); }
+ public void CloseSpan() { throw new NotImplementedException(); }
+ public void OpenTable(int cCols, VwLength vlWidth, int mpBorder, VwAlignment vwalign, VwFramePosition frmpos, VwRule vwrule, int mpSpacing, int mpPadding, bool fSelectOneCol) { throw new NotImplementedException(); }
+ public void CloseTable() { throw new NotImplementedException(); }
+ public void OpenTableRow() { throw new NotImplementedException(); }
+ public void CloseTableRow() { throw new NotImplementedException(); }
+ public void OpenTableCell(int nRowSpan, int nColSpan) { throw new NotImplementedException(); }
+ public void CloseTableCell() { throw new NotImplementedException(); }
+ public void OpenTableHeaderCell(int nRowSpan, int nColSpan) { throw new NotImplementedException(); }
+ public void CloseTableHeaderCell() { throw new NotImplementedException(); }
+ public void MakeColumns(int nColSpan, VwLength vlWidth) { throw new NotImplementedException(); }
+ public void MakeColumnGroup(int nColSpan, VwLength vlWidth) { throw new NotImplementedException(); }
+ public void OpenTableHeader() { throw new NotImplementedException(); }
+ public void CloseTableHeader() { throw new NotImplementedException(); }
+ public void OpenTableFooter() { throw new NotImplementedException(); }
+ public void CloseTableFooter() { throw new NotImplementedException(); }
+ public void OpenTableBody() { throw new NotImplementedException(); }
+ public void CloseTableBody() { throw new NotImplementedException(); }
+ public void set_StringProperty(int sp, string bstrValue) { throw new NotImplementedException(); }
+ public void AddPictureWithCaption(IPicture _pict, int tag, ITsTextProps _ttpCaption, int hvoCmFile, int ws, int dxmpWidth, int dympHeight, IVwViewConstructor _vwvc) { throw new NotImplementedException(); }
+ public void AddPicture(IPicture _pict, int tag, int dxmpWidth, int dympHeight) { throw new NotImplementedException(); }
+ public void SetParagraphMark(VwBoundaryMark boundaryMark) { throw new NotImplementedException(); }
+ public void EmptyParagraphBehavior(int behavior) { throw new NotImplementedException(); }
+ public bool IsParagraphOpen() { throw new NotImplementedException(); }
+ }
+ }
+}
diff --git a/Src/LexText/Morphology/RuleFormulaControl.cs b/Src/LexText/Morphology/RuleFormulaControl.cs
index 88035e5afa..b2dfafdc97 100644
--- a/Src/LexText/Morphology/RuleFormulaControl.cs
+++ b/Src/LexText/Morphology/RuleFormulaControl.cs
@@ -1150,7 +1150,9 @@ private void InitializeComponent()
this.m_view.Location = new System.Drawing.Point(0, 0);
this.m_view.Mediator = null;
this.m_view.Name = "m_view";
- this.m_view.ReadOnlyView = false;
+ // A rule formula cell is modifiable only via chooser-insert and delete, never free
+ // text.
+ this.m_view.ReadOnlyView = true;
this.m_view.ScrollMinSize = new System.Drawing.Size(0, 0);
this.m_view.ScrollPosition = new System.Drawing.Point(0, 0);
this.m_view.ShowRangeSelAfterLostFocus = false;
diff --git a/Src/LexText/Morphology/RuleFormulaVcBase.cs b/Src/LexText/Morphology/RuleFormulaVcBase.cs
index b316717761..b3f526bb3d 100644
--- a/Src/LexText/Morphology/RuleFormulaVcBase.cs
+++ b/Src/LexText/Morphology/RuleFormulaVcBase.cs
@@ -285,6 +285,9 @@ public override void Display(IVwEnv vwenv, int hvo, int frag)
break;
case kfragNC:
+ // The text belongs to the referenced natural class, not to the rule, so an
+ // edit here would rename it for every rule that uses it.
+ vwenv.set_IntProperty((int)FwTextPropType.ktptEditable, (int)FwTextPropVar.ktpvEnum, (int)TptEditable.ktptNotEditable);
int ncWs = WritingSystemServices.ActualWs(m_cache, WritingSystemServices.kwsFirstAnal, hvo,
PhNaturalClassTags.kflidAbbreviation);
if (ncWs != 0)
@@ -303,6 +306,9 @@ public override void Display(IVwEnv vwenv, int hvo, int frag)
break;
case kfragTerminalUnit:
+ // The text belongs to the referenced phoneme or boundary marker, so an edit
+ // here would rename it for every rule that uses it.
+ vwenv.set_IntProperty((int)FwTextPropType.ktptEditable, (int)FwTextPropVar.ktpvEnum, (int)TptEditable.ktptNotEditable);
int tuWs = WritingSystemServices.ActualWs(m_cache, WritingSystemServices.kwsFirstVern,
hvo, PhTerminalUnitTags.kflidName);
if (tuWs != 0)
@@ -320,14 +326,18 @@ public override void Display(IVwEnv vwenv, int hvo, int frag)
break;
case kfragFeature:
+ // This is a computed "abbreviation value" line, not free text.
+ vwenv.set_IntProperty((int)FwTextPropType.ktptEditable, (int)FwTextPropVar.ktpvEnum, (int)TptEditable.ktptNotEditable);
vwenv.AddProp(ktagFeature, this, kfragFeatureLine);
break;
case kfragPlusVariable:
+ vwenv.set_IntProperty((int)FwTextPropType.ktptEditable, (int)FwTextPropVar.ktpvEnum, (int)TptEditable.ktptNotEditable);
vwenv.AddProp(ktagVariable, this, kfragPlusVariableLine);
break;
case kfragMinusVariable:
+ vwenv.set_IntProperty((int)FwTextPropType.ktptEditable, (int)FwTextPropVar.ktpvEnum, (int)TptEditable.ktptNotEditable);
vwenv.AddProp(ktagVariable, this, kfragMinusVariableLine);
break;
}