Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Src/Common/Controls/XMLViews/MatchingObjectsBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,21 @@ public void Initialize(LcmCache cache, IVwStylesheet stylesheet, Mediator mediat
ResumeLayout(false);
}

/// <summary>
/// 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.
/// </summary>
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);
Expand Down
53 changes: 50 additions & 3 deletions Src/LexText/LexTextControls/EntryGoDlg.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,51 @@ protected override string PersistenceLabel
get { return "EntryGo"; }
}

/// <summary>
/// The default engine: full-text (word-prefix) matching. Cached in the property table.
/// </summary>
private SearchEngine FullTextSearchEngine
{
get
{
return SearchEngine.Get(m_mediator, m_propertyTable, "EntryGoSearchEngine",
() => new EntryGoSearchEngine(m_cache, SearchType.FullText));
}
}

/// <summary>
/// The substring (match-anywhere) engine. Built lazily the first time a query is long enough
/// to use substring matching (see <see cref="SubstringSearchPolicy"/>).
/// </summary>
private SearchEngine SubstringSearchEngine
{
get
{
return SearchEngine.Get(m_mediator, m_propertyTable, "EntryGoSubstringSearchEngine",
() => new EntryGoSearchEngine(m_cache, SearchType.Substring));
}
}

/// <summary>
/// True when this query should use substring matching. The decision (minimum query length)
/// lives in <see cref="SubstringSearchPolicy"/>. (Kept separate from <see cref="SearchEngineFor"/>
/// so callers can test the mode without instantiating the substring engine.)
/// </summary>
private bool UseSubstringFor(string searchKey)
{
return SubstringSearchPolicy.UseSubstring(searchKey);
}

/// <summary>
/// Choose the engine for a given search key: the substring engine only when
/// <see cref="UseSubstringFor"/> is true; otherwise the default full-text engine
/// (which still returns its normal results for short keys).
/// </summary>
private SearchEngine SearchEngineFor(string searchKey)
{
return UseSubstringFor(searchKey) ? SubstringSearchEngine : FullTextSearchEngine;
}

/// <summary>
/// Get/Set the starting entry object. This will not be displayed in the list of
/// matching entries.
Expand Down Expand Up @@ -85,10 +130,8 @@ protected override void InitializeMatchingObjects(LcmCache cache)
var xnWindow = m_propertyTable.GetValue<XmlNode>("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;

Expand All @@ -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
Expand Down Expand Up @@ -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));
}

Expand Down
4 changes: 2 additions & 2 deletions Src/LexText/LexTextControls/EntryGoSearchEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Virtuals>();
}
Expand Down
2 changes: 1 addition & 1 deletion Src/LexText/LexTextControls/LexTextControls.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
[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));
}
}
}
36 changes: 36 additions & 0 deletions Src/LexText/LexTextControls/SubstringSearchPolicy.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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 <see cref="EntryGoDlg"/>
/// so it can be unit-tested without driving the dialog.
/// </summary>
internal static class SubstringSearchPolicy
{
/// <summary>
/// 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).
/// </summary>
public const int MinQueryLength = 3;

/// <summary>
/// True when a query should use substring matching: the key is at least
/// <see cref="MinQueryLength"/> 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.
/// </summary>
/// <param name="searchKey">The (already trimmed) search key.</param>
public static bool UseSubstring(string searchKey)
{
return !string.IsNullOrEmpty(searchKey)
&& searchKey.Normalize(NormalizationForm.FormC).Length >= MinQueryLength;
}
}
}
Loading