|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Windows.Threading;
using System.Windows.Data;
using System.Windows.Input;
using MS.Win32;
using System.Globalization;
using System.Windows.Markup; // for XmlLanguage
using MS.Internal;
using MS.Internal.Data;
namespace System.Windows.Controls
{
// NTUser allows multiple selection while SHIFT is down. We should consider adopting this behavior (or similar).
//
// We have thoughts about how to give visual feedback about the text you are typing. This requires locating
// the actual text element within the object. This is probably more work than we will be able to do in M8.
// Need to discuss with MSX before committing to something here.
//
// Win32 listbox also seems to reset TypeSearch when selection changes or focus moves out.
// This means we need to track SelectionChanged and IsKeyboardFocusWithinChanged (or equivalent).
/// <summary>
/// Text Search is a feature that allows the user to quickly access items in a set by typing prefixes of the strings.
/// </summary>
public sealed class TextSearch : DependencyObject
{
/// <summary>
/// Make a new TextSearch instance attached to the given object.
/// Create the instance in the same context as the given DO.
/// </summary>
/// <param name="itemsControl"></param>
private TextSearch(ItemsControl itemsControl)
{
ArgumentNullException.ThrowIfNull(itemsControl);
_attachedTo = itemsControl;
ResetState();
}
/// <summary>
/// Get the instance of TextSearch attached to the given ItemsControl or make one and attach it if it's not.
/// </summary>
/// <param name="itemsControl"></param>
/// <returns></returns>
internal static TextSearch EnsureInstance(ItemsControl itemsControl)
{
TextSearch instance = (TextSearch)itemsControl.GetValue(TextSearchInstanceProperty);
if (instance == null)
{
instance = new TextSearch(itemsControl);
itemsControl.SetValue(TextSearchInstancePropertyKey, instance);
}
return instance;
}
#region Text and TextPath Properties
/// <summary>
/// Attached property to indicate which property on the item in the items collection to use for the "primary" text,
/// or the text against which to search.
/// </summary>
public static readonly DependencyProperty TextPathProperty
= DependencyProperty.RegisterAttached("TextPath", typeof(string), typeof(TextSearch),
new FrameworkPropertyMetadata(String.Empty /* default value */));
/// <summary>
/// Writes the attached property to the given element.
/// </summary>
/// <param name="element"></param>
/// <param name="path"></param>
public static void SetTextPath(DependencyObject element, string path)
{
ArgumentNullException.ThrowIfNull(element);
element.SetValue(TextPathProperty, path);
}
/// <summary>
/// Reads the attached property from the given element.
/// </summary>
/// <param name="element"></param>
/// <returns></returns>
[AttachedPropertyBrowsableForType(typeof(DependencyObject))]
public static string GetTextPath(DependencyObject element)
{
ArgumentNullException.ThrowIfNull(element);
return (string)element.GetValue(TextPathProperty);
}
/// <summary>
/// Attached property to indicate the value to use for the "primary" text of an element.
/// </summary>
public static readonly DependencyProperty TextProperty
= DependencyProperty.RegisterAttached("Text", typeof(string), typeof(TextSearch),
new FrameworkPropertyMetadata((string)String.Empty, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
/// <summary>
/// Writes the attached property to the given element.
/// </summary>
/// <param name="element"></param>
/// <param name="text"></param>
public static void SetText(DependencyObject element, string text)
{
ArgumentNullException.ThrowIfNull(element);
element.SetValue(TextProperty, text);
}
/// <summary>
/// Reads the attached property from the given element.
/// </summary>
/// <param name="element"></param>
/// <returns></returns>
[AttachedPropertyBrowsableForType(typeof(DependencyObject))]
public static string GetText(DependencyObject element)
{
ArgumentNullException.ThrowIfNull(element);
return (string)element.GetValue(TextProperty);
}
#endregion
#region Properties
/// <summary>
/// Prefix that is currently being used in the algorithm.
/// </summary>
private static readonly DependencyProperty CurrentPrefixProperty =
DependencyProperty.RegisterAttached("CurrentPrefix", typeof(string), typeof(TextSearch),
new FrameworkPropertyMetadata((string)null));
/// <summary>
/// If TextSearch is currently active.
/// </summary>
private static readonly DependencyProperty IsActiveProperty =
DependencyProperty.RegisterAttached("IsActive", typeof(bool), typeof(TextSearch),
new FrameworkPropertyMetadata(false));
#endregion
#region Private Properties
/// <summary>
/// The key needed set a read-only property.
/// </summary>
private static readonly DependencyPropertyKey TextSearchInstancePropertyKey =
DependencyProperty.RegisterAttachedReadOnly("TextSearchInstance", typeof(TextSearch), typeof(TextSearch),
new FrameworkPropertyMetadata((object)null /* default value */));
/// <summary>
/// Instance of TextSearch -- attached property so that the instance can be stored on the element
/// which wants the service.
/// </summary>
private static readonly DependencyProperty TextSearchInstanceProperty =
TextSearchInstancePropertyKey.DependencyProperty;
// used to retrieve the value of an item, according to the TextPath
private static readonly BindingExpressionUncommonField TextValueBindingExpression = new BindingExpressionUncommonField();
#endregion
#region Private Methods
/// <summary>
/// Called by consumers of TextSearch when a TextInput event is received
/// to kick off the algorithm.
/// </summary>
/// <param name="nextChar"></param>
/// <returns></returns>
internal bool DoSearch(string nextChar)
{
bool repeatedChar = false;
int startItemIndex = 0;
ItemCollection itemCollection = _attachedTo.Items as ItemCollection;
// If TextSearch is not active, then we should start
// the search from the beginning. If it is active, we should
// start the search from the currently-matched item.
if (IsActive)
{
// ISSUE: This falls victim to duplicate elements being in the view.
// To mitigate this, we could remember ItemUI ourselves.
startItemIndex = MatchedItemIndex;
}
// If they pressed the same character as last time, we will do the fallback search.
// Fallback search is if they type "bob" and then press "b"
// we'll look for "bobb" and when we don't find it we should
// find the next item starting with "bob".
if (_charsEntered.Count > 0 && string.Compare(_charsEntered[_charsEntered.Count - 1], nextChar, true, GetCulture(_attachedTo)) == 0)
{
repeatedChar = true;
}
// Get the primary TextPath from the ItemsControl to which we are attached.
string primaryTextPath = GetPrimaryTextPath(_attachedTo);
bool wasNewCharUsed = false;
int matchedItemIndex = FindMatchingPrefix(_attachedTo, primaryTextPath, Prefix,
nextChar, startItemIndex, repeatedChar, ref wasNewCharUsed);
// If there was an item that matched, move to that item in the collection
if (matchedItemIndex != -1)
{
// Don't have to move currency if it didn't actually move.
// startItemIndex is the index of the current item only if IsActive is true,
// So, we have to move currency when IsActive is false.
if (!IsActive || matchedItemIndex != startItemIndex)
{
object matchedItem = itemCollection[matchedItemIndex];
// Let the control decide what to do with matched-item
_attachedTo.NavigateToItem(matchedItem, matchedItemIndex, new ItemsControl.ItemNavigateArgs(Keyboard.PrimaryDevice, ModifierKeys.None));
// Store current match
MatchedItemIndex = matchedItemIndex;
}
// Update the prefix if it changed
if (wasNewCharUsed)
{
AddCharToPrefix(nextChar);
}
// User has started typing (successfully), so we're active now.
if (!IsActive)
{
IsActive = true;
}
}
// Reset the timeout and remember this character, but only if we're
// active -- this is because if we got called but the match failed
// we don't need to set up a timeout -- no state needs to be reset.
if (IsActive)
{
ResetTimeout();
}
return (matchedItemIndex != -1);
}
/// <summary>
/// Called when the user presses backspace.
/// </summary>
/// <returns></returns>
internal bool DeleteLastCharacter()
{
if (IsActive)
{
// Remove the last character from the prefix string.
// Get the last character entered and then remove a string of
// that length off the prefix string.
if (_charsEntered.Count > 0)
{
string lastChar = _charsEntered[_charsEntered.Count - 1];
string prefix = Prefix;
_charsEntered.RemoveAt(_charsEntered.Count - 1);
Prefix = prefix.Substring(0, prefix.Length - lastChar.Length);
ResetTimeout();
return true;
}
}
return false;
}
/// <summary>
/// Gets the length of the prefix (the prefix of matchedText matched by newText) and the rest of the string from the matchedText
/// It takes care of compressions or expansions in both matchedText and newText which could be impacting the length of the string
/// For example: length of prefix would be 5 and the rest would be 2 if matchedText is "Grosses" and newText is ""Groß"
/// length of prefix would be 4 and the rest would be 2 if matchedText is ""Großes" and newText is "Gross" as "ß" = "ss"
/// </summary>
/// /// <param name="matchedText">string that is assumed to contain prefix which matches newText</param>
/// <param name="newText">string that is assumed to match a prefix of matchedText</param>
private static void GetMatchingPrefixAndRemainingTextLength(string matchedText, string newText, CultureInfo cultureInfo,
bool ignoreCase, out int matchedPrefixLength, out int textExcludingPrefixLength)
{
Debug.Assert(String.IsNullOrEmpty(matchedText) == false, "matchedText cannot be null or empty");
Debug.Assert(String.IsNullOrEmpty(newText) == false, "newText cannot be null or empty");
Debug.Assert(matchedText.StartsWith(newText, ignoreCase, cultureInfo), "matchedText should start with newText");
matchedPrefixLength = 0;
textExcludingPrefixLength = 0;
if (matchedText.Length < newText.Length)
{
matchedPrefixLength = matchedText.Length;
textExcludingPrefixLength = 0;
}
else
{
// mostly compression or expansion is not involved. So start with length of newText
int i = newText.Length;
int j = i + 1;
CompareInfo compareInfo = (cultureInfo ?? CultureInfo.CurrentCulture).CompareInfo;
CompareOptions options = ignoreCase ? CompareOptions.IgnoreCase : CompareOptions.None;
do
{
ReadOnlySpan<char> temp;
if (i >= 1)
{
temp = matchedText.AsSpan(0, i);
if (compareInfo.Compare(newText, temp, options) == 0)
{
matchedPrefixLength = i;
textExcludingPrefixLength = matchedText.Length - i;
break;
}
}
if (j <= matchedText.Length)
{
temp = matchedText.AsSpan(0, j);
if (compareInfo.Compare(newText, temp, options) == 0)
{
matchedPrefixLength = j;
textExcludingPrefixLength = matchedText.Length - j;
break;
}
}
i--;
j++;
} while (i >= 1 || j <= matchedText.Length);
}
}
/// <summary>
/// Searches through the given itemCollection for the first item matching the given prefix.
/// </summary>
/// <remarks>
/// --------------------------------------------------------------------------
/// Incremental Type Search algorithm
/// --------------------------------------------------------------------------
///
/// Given a prefix and new character, we loop through all items in the collection
/// and look for an item that starts with the new prefix. If we find such an item,
/// select it. If the new character is repeated, we look for the next item after
/// the current one that begins with the old prefix**. We can optimize by
/// performing both of these searches in parallel.
///
/// **NOTE: Win32 will only do this if the old prefix is of length 1 - in other
/// words, first-character-only matching. The algorithm described here
/// is an extension of ITS as implemented in Win32. This variant was
/// described to me by JeffBog as what was done in AFC - but I have yet
/// to find a listbox which behaves this way.
///
/// --------------------------------------------------------------------------
/// </remarks>
/// <returns>Item that matches the given prefix</returns>
private static int FindMatchingPrefix(ItemsControl itemsControl, string primaryTextPath, string prefix,
string newChar, int startItemIndex, bool lookForFallbackMatchToo, ref bool wasNewCharUsed)
{
ItemCollection itemCollection = itemsControl.Items;
// Using indices b/c this is a better way to uniquely
// identify an element in the collection.
int matchedItemIndex = -1;
int fallbackMatchIndex = -1;
int count = itemCollection.Count;
// Return immediately with no match if there were no items in the view.
if (count == 0)
{
return -1;
}
string newPrefix = prefix + newChar;
// With an empty prefix, we'd match anything
if (String.IsNullOrEmpty(newPrefix))
{
return -1;
}
// Hook up the binding we will apply to each object. Get the
// PrimaryTextPath off of the attached instance and then make
// a binding with that path.
BindingExpression primaryTextBinding = null;
object item0 = itemsControl.Items[0];
bool useXml = SystemXmlHelper.IsXmlNode(item0);
if (useXml || !String.IsNullOrEmpty(primaryTextPath))
{
primaryTextBinding = CreateBindingExpression(itemsControl, item0, primaryTextPath);
TextValueBindingExpression.SetValue(itemsControl, primaryTextBinding);
}
bool firstItem = true;
wasNewCharUsed = false;
CultureInfo cultureInfo = GetCulture(itemsControl);
// ISSUE: what about changing the collection while this is running?
for (int currentIndex = startItemIndex; currentIndex < count; )
{
object item = itemCollection[currentIndex];
if (item != null)
{
string itemString = GetPrimaryText(item, primaryTextBinding, itemsControl);
bool isTextSearchCaseSensitive = itemsControl.IsTextSearchCaseSensitive;
// See if the current item matches the newPrefix, if so we can
// stop searching and accept this item as the match.
if (itemString != null && itemString.StartsWith(newPrefix, !isTextSearchCaseSensitive, cultureInfo))
{
// Accept the new prefix as the current prefix.
wasNewCharUsed = true;
matchedItemIndex = currentIndex;
break;
}
// Find the next string that matches the last prefix. This
// string will be used in the case that the new prefix isn't
// matched. This enables pressing the last character multiple
// times and cylcing through the set of items that match that
// prefix.
//
// Unlike the above search, this search must start *after*
// the currently selected item. This search also shouldn't
// happen if there was no previous prefix to match against
if (lookForFallbackMatchToo)
{
if (!firstItem && prefix != String.Empty)
{
if (itemString != null)
{
if (fallbackMatchIndex == -1 && itemString.StartsWith(prefix, !isTextSearchCaseSensitive, cultureInfo))
{
fallbackMatchIndex = currentIndex;
}
}
}
else
{
firstItem = false;
}
}
}
// Move next and wrap-around if we pass the end of the container.
currentIndex++;
if (currentIndex >= count)
{
currentIndex = 0;
}
// Stop where we started but only after the first pass
// through the loop -- we should process the startItem.
if (currentIndex == startItemIndex)
{
break;
}
}
if (primaryTextBinding != null)
{
// Clean up the binding for the primary text path.
TextValueBindingExpression.ClearValue(itemsControl);
}
// In the case that the new prefix didn't match anything and
// there was a fallback match that matched the old prefix, move
// to that one.
if (matchedItemIndex == -1 && fallbackMatchIndex != -1)
{
matchedItemIndex = fallbackMatchIndex;
}
return matchedItemIndex;
}
/// <summary>
/// Helper function called by Editable ComboBox to search through items.
/// </summary>
internal static MatchedTextInfo FindMatchingPrefix(ItemsControl itemsControl, string prefix)
{
MatchedTextInfo matchedTextInfo;
bool wasNewCharUsed = false;
int matchedItemIndex = FindMatchingPrefix(itemsControl, GetPrimaryTextPath(itemsControl), prefix, String.Empty, 0, false, ref wasNewCharUsed);
// There could be compressions or expansions in either matched text or inputted text which means
// length of the prefix in the matched text and length of the inputted text could be different
// for example: "Grosses" would match for the input text "Groß" where the prefix length in matched text is 5
// whereas the length of the inputted text is 4. Same matching rule applies for the other way as well with
// "Groß" in matched text for the inputted text "Gross"
if (matchedItemIndex >= 0)
{
int matchedPrefixLength;
int textExcludingPrefixLength;
CultureInfo cultureInfo = GetCulture(itemsControl);
bool ignoreCase = itemsControl.IsTextSearchCaseSensitive;
string matchedText = GetPrimaryTextFromItem(itemsControl, itemsControl.Items[matchedItemIndex]);
GetMatchingPrefixAndRemainingTextLength(matchedText, prefix, cultureInfo, !ignoreCase, out matchedPrefixLength, out textExcludingPrefixLength);
matchedTextInfo = new MatchedTextInfo(matchedItemIndex, matchedText, matchedPrefixLength, textExcludingPrefixLength);
}
else
{
matchedTextInfo = MatchedTextInfo.NoMatch;
}
return matchedTextInfo;
}
private void ResetTimeout()
{
// Called when we get some input. Start or reset the timer.
// Queue an inactive priority work item and set its deadline.
if (_timeoutTimer == null)
{
_timeoutTimer = new DispatcherTimer(DispatcherPriority.Normal);
_timeoutTimer.Tick += new EventHandler(OnTimeout);
}
else
{
_timeoutTimer.Stop();
}
// Schedule this operation to happen a certain number of milliseconds from now.
_timeoutTimer.Interval = TimeOut;
_timeoutTimer.Start();
}
private void AddCharToPrefix(string newChar)
{
Prefix += newChar;
_charsEntered.Add(newChar);
}
private static string GetPrimaryTextPath(ItemsControl itemsControl)
{
string primaryTextPath = (string)itemsControl.GetValue(TextPathProperty);
if (String.IsNullOrEmpty(primaryTextPath))
{
primaryTextPath = itemsControl.DisplayMemberPath;
}
return primaryTextPath;
}
private static string GetPrimaryText(object item, BindingExpression primaryTextBinding, DependencyObject primaryTextBindingHome)
{
// Order of precedence for getting Primary Text is as follows:
//
// 1) PrimaryText
// 2) PrimaryTextPath (TextSearch.TextPath or ItemsControl.DisplayMemberPath)
// 3) GetPlainText()
// 4) ToString()
DependencyObject itemDO = item as DependencyObject;
if (itemDO != null)
{
string primaryText = (string)itemDO.GetValue(TextProperty);
if (!String.IsNullOrEmpty(primaryText))
{
return primaryText;
}
}
// Here hopefully they've supplied a path into their object which we can use.
if (primaryTextBinding != null && primaryTextBindingHome != null)
{
// Take the binding that we hooked up at the beginning of the search
// and apply it to the current item. Then, read the value of the
// ItemPrimaryText property (where the binding actually lives).
// Try to convert the resulting object to a string.
primaryTextBinding.Activate(item);
object primaryText = primaryTextBinding.Value;
return ConvertToPlainText(primaryText);
}
return ConvertToPlainText(item);
}
private static string ConvertToPlainText(object o)
{
FrameworkElement fe = o as FrameworkElement;
// Try to return FrameworkElement.GetPlainText()
if (fe != null)
{
string text = fe.GetPlainText();
if (text != null)
{
return text;
}
}
// Try to convert the item to a string
return (o != null) ? o.ToString() : String.Empty;
}
/// <summary>
/// Internal helper method that uses the same primary text lookup steps but doesn't require
/// the user passing in all of the bindings that we need.
/// </summary>
/// <param name="itemsControl"></param>
/// <param name="item"></param>
/// <returns></returns>
internal static string GetPrimaryTextFromItem(ItemsControl itemsControl, object item)
{
if (item == null)
return String.Empty;
BindingExpression primaryTextBinding = CreateBindingExpression(itemsControl, item, GetPrimaryTextPath(itemsControl));
TextValueBindingExpression.SetValue(itemsControl, primaryTextBinding);
string primaryText = GetPrimaryText(item, primaryTextBinding, itemsControl);
TextValueBindingExpression.ClearValue(itemsControl);
return primaryText;
}
private static BindingExpression CreateBindingExpression(ItemsControl itemsControl, object item, string primaryTextPath)
{
Binding binding = new Binding();
// Use xpath for xmlnodes (See Selector.PrepareItemValueBinding)
if (SystemXmlHelper.IsXmlNode(item))
{
binding.XPath = primaryTextPath;
binding.Path = new PropertyPath("/InnerText");
}
else
{
binding.Path = new PropertyPath(primaryTextPath);
}
binding.Mode = BindingMode.OneWay;
binding.Source = null;
return (BindingExpression)BindingExpression.CreateUntargetedBindingExpression(itemsControl, binding);
}
private void OnTimeout(object sender, EventArgs e)
{
ResetState();
}
private void ResetState()
{
// Reset the prefix string back to empty.
IsActive = false;
Prefix = String.Empty;
MatchedItemIndex = -1;
if (_charsEntered == null)
{
_charsEntered = new List<string>(10);
}
else
{
_charsEntered.Clear();
}
if(_timeoutTimer != null)
{
_timeoutTimer.Stop();
}
_timeoutTimer = null;
}
/// <summary>
/// Time until the search engine resets.
/// </summary>
private TimeSpan TimeOut
{
get
{
// NOTE: NtUser does the following (file: windows/ntuser/kernel/sysmet.c)
// gpsi->dtLBSearch = dtTime * 4; // dtLBSearch = 4 * gdtDblClk
// gpsi->dtScroll = gpsi->dtLBSearch / 5; // dtScroll = 4/5 * gdtDblClk
//
// 4 * DoubleClickSpeed seems too slow for the search
// So for now we'll do 2 * DoubleClickSpeed
return TimeSpan.FromMilliseconds(SafeNativeMethods.GetDoubleClickTime() * 2);
}
}
#endregion
#region Testing API
// Being that this is a time-sensitive operation, it's difficult
// to get the timing right in a DRT. I'll leave input testing up to BVTs here
// but this internal API is for the DRT to do basic coverage.
private static TextSearch GetInstance(DependencyObject d)
{
return EnsureInstance(d as ItemsControl);
}
private void TypeAKey(string c)
{
DoSearch(c);
}
private void CauseTimeOut()
{
if (_timeoutTimer != null)
{
_timeoutTimer.Stop();
OnTimeout(_timeoutTimer, EventArgs.Empty);
}
}
internal string GetCurrentPrefix()
{
return Prefix;
}
#endregion
#region Internal Accessibility API
internal static string GetPrimaryText(FrameworkElement element)
{
ArgumentNullException.ThrowIfNull(element);
string text = (string)element.GetValue(TextProperty);
if (text != null && text != String.Empty)
{
return text;
}
return element.GetPlainText();
}
#endregion
#region Private Fields
private string Prefix
{
get { return _prefix; }
set
{
_prefix = value;
#if DEBUG
// Also need to invalidate the property CurrentPrefixProperty on the instance to which we are attached.
Debug.Assert(_attachedTo != null);
_attachedTo.SetValue(CurrentPrefixProperty, _prefix);
#endif
}
}
private bool IsActive
{
get { return _isActive; }
set
{
_isActive = value;
#if DEBUG
Debug.Assert(_attachedTo != null);
_attachedTo.SetValue(IsActiveProperty, _isActive);
#endif
}
}
private int MatchedItemIndex
{
get { return _matchedItemIndex; }
set
{
_matchedItemIndex = value;
}
}
private static CultureInfo GetCulture(DependencyObject element)
{
object o = element.GetValue(FrameworkElement.LanguageProperty);
CultureInfo culture = null;
if (o != null)
{
XmlLanguage language = (XmlLanguage) o;
try
{
culture = language.GetSpecificCulture();
}
catch (InvalidOperationException)
{
}
}
return culture;
}
// Element to which this TextSearch instance is attached.
private ItemsControl _attachedTo;
// String of characters matched so far.
private string _prefix;
private List<string> _charsEntered;
private bool _isActive;
private int _matchedItemIndex;
private DispatcherTimer _timeoutTimer;
#endregion
}
}
|