From fa66aa8e7ba13a0cb6d61e48a789d2d412374a8f Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Fri, 14 Aug 2026 11:28:51 -0400 Subject: [PATCH 1/3] LT-22524: Add substring search to Find Lexical Entry Find Lexical Entry now matches typed characters anywhere in a word for queries of 3+ characters, using liblcm's new SearchType.Substring; shorter queries keep the original full-text search. The mode decision lives in a unit-tested SubstringSearchPolicy, and results retain the existing exact -> starts-with -> anywhere ordering. --- .../XMLViews/MatchingObjectsBrowser.cs | 15 +++++ Src/LexText/LexTextControls/EntryGoDlg.cs | 53 ++++++++++++++++- .../LexTextControls/EntryGoSearchEngine.cs | 4 +- .../LexTextControls.Designer.cs | 2 +- .../SubstringSearchPolicyTests.cs | 58 +++++++++++++++++++ .../LexTextControls/SubstringSearchPolicy.cs | 36 ++++++++++++ 6 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 Src/LexText/LexTextControls/LexTextControlsTests/SubstringSearchPolicyTests.cs create mode 100644 Src/LexText/LexTextControls/SubstringSearchPolicy.cs 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; + } + } +} From 6a56fb8ceffd2411f8f428e4e3337acaa8127a99 Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Tue, 25 Aug 2026 17:41:20 -0400 Subject: [PATCH 2/3] Comment cleanup --- Src/LexText/LexTextControls/EntryGoDlg.cs | 15 ++++++++------- .../SubstringSearchPolicyTests.cs | 14 +++++++------- .../LexTextControls/SubstringSearchPolicy.cs | 19 ++++++++++--------- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/Src/LexText/LexTextControls/EntryGoDlg.cs b/Src/LexText/LexTextControls/EntryGoDlg.cs index ba1041f511..3335005c6e 100644 --- a/Src/LexText/LexTextControls/EntryGoDlg.cs +++ b/Src/LexText/LexTextControls/EntryGoDlg.cs @@ -54,8 +54,8 @@ private SearchEngine FullTextSearchEngine } /// - /// The substring (match-anywhere) engine. Built lazily the first time a query is long enough - /// to use substring matching (see ). + /// The substring (match-anywhere) engine. Built lazily the first time a query is long + /// enough to use substring matching (see ). /// private SearchEngine SubstringSearchEngine { @@ -67,9 +67,10 @@ private SearchEngine SubstringSearchEngine } /// - /// 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.) + /// 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) { @@ -202,8 +203,8 @@ 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. + // 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/LexTextControlsTests/SubstringSearchPolicyTests.cs b/Src/LexText/LexTextControls/LexTextControlsTests/SubstringSearchPolicyTests.cs index b795c4215f..1e870e076a 100644 --- a/Src/LexText/LexTextControls/LexTextControlsTests/SubstringSearchPolicyTests.cs +++ b/Src/LexText/LexTextControls/LexTextControlsTests/SubstringSearchPolicyTests.cs @@ -8,14 +8,14 @@ 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. + /// 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. + // 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)] @@ -37,9 +37,9 @@ public void UseSubstring_nullKey_isFalse() [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. + // 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 diff --git a/Src/LexText/LexTextControls/SubstringSearchPolicy.cs b/Src/LexText/LexTextControls/SubstringSearchPolicy.cs index ccca72cbff..22fb7cfef8 100644 --- a/Src/LexText/LexTextControls/SubstringSearchPolicy.cs +++ b/Src/LexText/LexTextControls/SubstringSearchPolicy.cs @@ -7,24 +7,25 @@ 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. + /// 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). + /// 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. + /// 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) From f30f2a469d04ae8870decb81cac3e8c8656df74a Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Tue, 25 Aug 2026 18:15:55 -0400 Subject: [PATCH 3/3] Liblcm version dependency --- Build/SilVersions.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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