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; + } + } +}