diff --git a/Build/SilVersions.props b/Build/SilVersions.props index 551972abe1..3de6c4fb0d 100644 --- a/Build/SilVersions.props +++ b/Build/SilVersions.props @@ -12,7 +12,7 @@ ============================================================= --> - 11.0.0-beta0178 + 11.0.0-beta0180 18.0.0-beta0030 18.0.0-beta0012 6.0.0-beta0065 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..3335005c6e 100644 --- a/Src/LexText/LexTextControls/EntryGoDlg.cs +++ b/Src/LexText/LexTextControls/EntryGoDlg.cs @@ -41,6 +41,52 @@ 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 +131,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 +141,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 +203,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 full-text so short keys behave like the original. + 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..1e870e076a --- /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 +{ + /// + /// Verifies the query-length gate that decides when Find Lexical Entry uses substring + /// (match-anywhere) matching. + /// + [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. Counting raw + // Length would see 4 and 6 (both >= 3) and wrongly enable substring; composed + // characters count as 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..22fb7cfef8 --- /dev/null +++ b/Src/LexText/LexTextControls/SubstringSearchPolicy.cs @@ -0,0 +1,37 @@ +// 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; + } + } +}