diff --git a/Src/Common/Controls/XMLViews/MatchingObjectsBrowser.cs b/Src/Common/Controls/XMLViews/MatchingObjectsBrowser.cs
index 3f9627c211..89772c5428 100644
--- a/Src/Common/Controls/XMLViews/MatchingObjectsBrowser.cs
+++ b/Src/Common/Controls/XMLViews/MatchingObjectsBrowser.cs
@@ -205,6 +205,21 @@ public void Initialize(LcmCache cache, IVwStylesheet stylesheet, Mediator mediat
ResumeLayout(false);
}
+ ///
+ /// Swap the active search engine (e.g. when toggling substring-match mode). The caller
+ /// owns the lifetime of both engines; this only re-hooks the SearchCompleted event.
+ ///
+ public void SetSearchEngine(SearchEngine searchEngine)
+ {
+ CheckDisposed();
+ if (ReferenceEquals(m_searchEngine, searchEngine))
+ return;
+ if (m_searchEngine != null)
+ m_searchEngine.SearchCompleted -= m_searchEngine_SearchCompleted;
+ m_searchEngine = searchEngine;
+ m_searchEngine.SearchCompleted += m_searchEngine_SearchCompleted;
+ }
+
private void m_searchEngine_SearchCompleted(object sender, SearchCompletedEventArgs e)
{
UpdateResults(e.Fields.FirstOrDefault(), e.Results);
diff --git a/Src/LexText/LexTextControls/EntryGoDlg.cs b/Src/LexText/LexTextControls/EntryGoDlg.cs
index 1edac295ad..ba1041f511 100644
--- a/Src/LexText/LexTextControls/EntryGoDlg.cs
+++ b/Src/LexText/LexTextControls/EntryGoDlg.cs
@@ -41,6 +41,51 @@ protected override string PersistenceLabel
get { return "EntryGo"; }
}
+ ///
+ /// The default engine: full-text (word-prefix) matching. Cached in the property table.
+ ///
+ private SearchEngine FullTextSearchEngine
+ {
+ get
+ {
+ return SearchEngine.Get(m_mediator, m_propertyTable, "EntryGoSearchEngine",
+ () => new EntryGoSearchEngine(m_cache, SearchType.FullText));
+ }
+ }
+
+ ///
+ /// The substring (match-anywhere) engine. Built lazily the first time a query is long enough
+ /// to use substring matching (see ).
+ ///
+ private SearchEngine SubstringSearchEngine
+ {
+ get
+ {
+ return SearchEngine.Get(m_mediator, m_propertyTable, "EntryGoSubstringSearchEngine",
+ () => new EntryGoSearchEngine(m_cache, SearchType.Substring));
+ }
+ }
+
+ ///
+ /// True when this query should use substring matching. The decision (minimum query length)
+ /// lives in . (Kept separate from
+ /// so callers can test the mode without instantiating the substring engine.)
+ ///
+ private bool UseSubstringFor(string searchKey)
+ {
+ return SubstringSearchPolicy.UseSubstring(searchKey);
+ }
+
+ ///
+ /// Choose the engine for a given search key: the substring engine only when
+ /// is true; otherwise the default full-text engine
+ /// (which still returns its normal results for short keys).
+ ///
+ private SearchEngine SearchEngineFor(string searchKey)
+ {
+ return UseSubstringFor(searchKey) ? SubstringSearchEngine : FullTextSearchEngine;
+ }
+
///
/// Get/Set the starting entry object. This will not be displayed in the list of
/// matching entries.
@@ -85,10 +130,8 @@ protected override void InitializeMatchingObjects(LcmCache cache)
var xnWindow = m_propertyTable.GetValue("WindowConfiguration");
XmlNode configNode = xnWindow.SelectSingleNode("controls/parameters/guicontrol[@id=\"matchingEntries\"]/parameters");
- SearchEngine searchEngine = SearchEngine.Get(m_mediator, m_propertyTable, "EntryGoSearchEngine", () => new EntryGoSearchEngine(cache));
-
m_matchingObjectsBrowser.Initialize(cache, FontHeightAdjuster.StyleSheetFromPropertyTable(m_propertyTable), m_mediator, m_propertyTable, configNode,
- searchEngine);
+ SearchEngineFor(string.Empty));
m_matchingObjectsBrowser.ColumnsChanged += m_matchingObjectsBrowser_ColumnsChanged;
@@ -97,6 +140,7 @@ protected override void InitializeMatchingObjects(LcmCache cache)
if (selectedWs != null)
m_matchingObjectsBrowser.SearchAsync(GetFields(string.Empty, selectedWs.Handle));
}
+
#endregion Construction and Destruction
#region Other methods
@@ -158,6 +202,9 @@ protected override void ResetMatches(string searchKey)
m_oldSearchKey = searchKey;
m_oldSearchWs = wsSelHvo;
+ // Select the engine for this query: substring once the key is long enough (>= MinQueryLength);
+ // otherwise the default full-text engine, so short keys behave like the original search.
+ m_matchingObjectsBrowser.SetSearchEngine(SearchEngineFor(searchKey));
m_matchingObjectsBrowser.SearchAsync(GetFields(searchKey, wsSelHvo));
}
diff --git a/Src/LexText/LexTextControls/EntryGoSearchEngine.cs b/Src/LexText/LexTextControls/EntryGoSearchEngine.cs
index a47d66ad00..3a4a9cce66 100644
--- a/Src/LexText/LexTextControls/EntryGoSearchEngine.cs
+++ b/Src/LexText/LexTextControls/EntryGoSearchEngine.cs
@@ -20,8 +20,8 @@ internal class EntryGoSearchEngine : SearchEngine
{
private readonly Virtuals m_virtuals;
- public EntryGoSearchEngine(LcmCache cache)
- : base(cache, SearchType.FullText)
+ public EntryGoSearchEngine(LcmCache cache, SearchType searchType = SearchType.FullText)
+ : base(cache, searchType)
{
m_virtuals = Cache.ServiceLocator.GetInstance();
}
diff --git a/Src/LexText/LexTextControls/LexTextControls.Designer.cs b/Src/LexText/LexTextControls/LexTextControls.Designer.cs
index 8611971f6b..e581cf3f2f 100644
--- a/Src/LexText/LexTextControls/LexTextControls.Designer.cs
+++ b/Src/LexText/LexTextControls/LexTextControls.Designer.cs
@@ -1149,7 +1149,7 @@ internal static string ksFindLexEntry {
return ResourceManager.GetString("ksFindLexEntry", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Find Record.
///
diff --git a/Src/LexText/LexTextControls/LexTextControlsTests/SubstringSearchPolicyTests.cs b/Src/LexText/LexTextControls/LexTextControlsTests/SubstringSearchPolicyTests.cs
new file mode 100644
index 0000000000..b795c4215f
--- /dev/null
+++ b/Src/LexText/LexTextControls/LexTextControlsTests/SubstringSearchPolicyTests.cs
@@ -0,0 +1,58 @@
+// 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.FieldWorks.LexText.Controls;
+
+namespace LexTextControlsTests
+{
+ ///
+ /// Tests for the pure Find-Lexical-Entry substring-search decision logic that was extracted from
+ /// EntryGoDlg so it could be tested without driving the dialog.
+ ///
+ [TestFixture]
+ public class SubstringSearchPolicyTests
+ {
+ // A base letter followed by a combining acute accent (U+0301): 2 UTF-16 units that compose to
+ // a single character under FormC normalization.
+ private static readonly string ComposedAcuteE = "e" + (char)0x0301;
+
+ [TestCase("", ExpectedResult = false)]
+ [TestCase("l", ExpectedResult = false)]
+ [TestCase("la", ExpectedResult = false)] // below MinQueryLength
+ [TestCase("lan", ExpectedResult = true)] // exactly MinQueryLength
+ [TestCase("language", ExpectedResult = true)]
+ public bool UseSubstring_gatesOnLength(string key)
+ {
+ return SubstringSearchPolicy.UseSubstring(key);
+ }
+
+ [Test]
+ public void UseSubstring_nullKey_isFalse()
+ {
+ Assert.That(SubstringSearchPolicy.UseSubstring(null), Is.False);
+ }
+
+ [Test]
+ public void UseSubstring_countsComposedCharacters_notUtf16Units()
+ {
+ // Each ComposedAcuteE is 2 UTF-16 units but 1 character after FormC. If the policy counted
+ // raw Length it would see 4 and 6 (both >= 3) and wrongly enable substring; counting composed
+ // characters it sees 2 and 3.
+ string twoComposed = ComposedAcuteE + ComposedAcuteE; // raw Length 4 -> 2
+ string threeComposed = ComposedAcuteE + ComposedAcuteE + ComposedAcuteE; // raw Length 6 -> 3
+
+ Assert.That(SubstringSearchPolicy.UseSubstring(twoComposed), Is.False,
+ "two composed characters should count as length 2, below the threshold");
+ Assert.That(SubstringSearchPolicy.UseSubstring(threeComposed), Is.True,
+ "three composed characters should count as length 3, at the threshold");
+ }
+
+ [Test]
+ public void MinQueryLength_hasExpectedDefault()
+ {
+ Assert.That(SubstringSearchPolicy.MinQueryLength, Is.EqualTo(3));
+ }
+ }
+}
diff --git a/Src/LexText/LexTextControls/SubstringSearchPolicy.cs b/Src/LexText/LexTextControls/SubstringSearchPolicy.cs
new file mode 100644
index 0000000000..ccca72cbff
--- /dev/null
+++ b/Src/LexText/LexTextControls/SubstringSearchPolicy.cs
@@ -0,0 +1,36 @@
+// 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.Text;
+
+namespace SIL.FieldWorks.LexText.Controls
+{
+ ///
+ /// UI-independent decision logic for the Find Lexical Entry "match anywhere" (substring) search
+ /// mode: when substring matching applies, plus its tuning knobs. Kept out of
+ /// so it can be unit-tested without driving the dialog.
+ ///
+ internal static class SubstringSearchPolicy
+ {
+ ///
+ /// Substring matching engages only once the query is at least this many characters; shorter
+ /// keys fall back to the default full-text engine, so short queries behave like the original
+ /// search (a 1-2 char substring in a large project would otherwise match most entries).
+ ///
+ public const int MinQueryLength = 3;
+
+ ///
+ /// True when a query should use substring matching: the key is at least
+ /// characters. Length is counted after FormC normalization, so a
+ /// base character plus a combining diacritic counts as one character. Shorter keys fall back to
+ /// the default full-text search.
+ ///
+ /// The (already trimmed) search key.
+ public static bool UseSubstring(string searchKey)
+ {
+ return !string.IsNullOrEmpty(searchKey)
+ && searchKey.Normalize(NormalizationForm.FormC).Length >= MinQueryLength;
+ }
+ }
+}