UnityGame/Library/PackageCache/com.unity.searcher/Editor/Searcher/SearcherControl.cs
2024-10-27 10:53:47 +03:00

807 lines
29 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.UIElements;
namespace UnityEditor.Searcher
{
class SearcherControl : VisualElement
{
// Window constants.
const string k_WindowTitleLabel = "windowTitleLabel";
const string k_WindowDetailsPanel = "windowDetailsVisualContainer";
const string k_WindowResultsScrollViewName = "windowResultsScrollView";
const string k_WindowSearchTextFieldName = "searchBox";
const string k_WindowAutoCompleteLabelName = "autoCompleteLabel";
const string k_WindowSearchIconName = "searchIcon";
const string k_WindowResizerName = "windowResizer";
const string kWindowSearcherPanel = "searcherVisualContainer";
const int k_TabCharacter = 9;
Label m_AutoCompleteLabel;
IEnumerable<SearcherItem> m_Results;
List<SearcherItem> m_VisibleResults;
HashSet<SearcherItem> m_ExpandedResults;
HashSet<SearcherItem> m_MultiSelectSelection;
Dictionary<SearcherItem, Toggle> m_SearchItemToVisualToggle;
Searcher m_Searcher;
string m_SuggestedTerm;
string m_Text = string.Empty;
Action<SearcherItem> m_SelectionCallback;
Action<Searcher.AnalyticsEvent> m_AnalyticsDataCallback;
Func<IEnumerable<SearcherItem>, string, SearcherItem> m_SearchResultsFilterCallback;
ListView m_ListView;
TextField m_SearchTextField;
VisualElement m_SearchTextInput;
VisualElement m_DetailsPanel;
VisualElement m_SearcherPanel;
VisualElement m_ContentContainer;
Button m_ConfirmButton;
internal Label TitleLabel { get; }
internal VisualElement Resizer { get; }
public SearcherControl()
{
// Load window template.
var windowUxmlTemplate = Resources.Load<VisualTreeAsset>("SearcherWindow");
// Clone Window Template.
var windowRootVisualElement = windowUxmlTemplate.CloneTree();
windowRootVisualElement.AddToClassList("content");
windowRootVisualElement.StretchToParentSize();
// Add Window VisualElement to window's RootVisualContainer
Add(windowRootVisualElement);
m_VisibleResults = new List<SearcherItem>();
m_ExpandedResults = new HashSet<SearcherItem>();
m_MultiSelectSelection = new HashSet<SearcherItem>();
m_SearchItemToVisualToggle = new Dictionary<SearcherItem, Toggle>();
m_ListView = this.Q<ListView>(k_WindowResultsScrollViewName);
if (m_ListView != null)
{
m_ListView.bindItem = Bind;
m_ListView.RegisterCallback<KeyDownEvent>(SetSelectedElementInResultsList);
#if UNITY_2020_1_OR_NEWER
m_ListView.onItemsChosen += obj => OnListViewSelect((SearcherItem)obj.FirstOrDefault());
m_ListView.onSelectionChange += selectedItems => m_Searcher.Adapter.OnSelectionChanged(selectedItems.OfType<SearcherItem>().ToList());
#else
m_ListView.onItemChosen += obj => OnListViewSelect((SearcherItem)obj);
m_ListView.onSelectionChanged += selectedItems => m_Searcher.Adapter.OnSelectionChanged(selectedItems.OfType<SearcherItem>());
#endif
m_ListView.focusable = true;
m_ListView.tabIndex = 1;
}
m_DetailsPanel = this.Q(k_WindowDetailsPanel);
TitleLabel = this.Q<Label>(k_WindowTitleLabel);
m_SearcherPanel = this.Q(kWindowSearcherPanel);
m_SearchTextField = this.Q<TextField>(k_WindowSearchTextFieldName);
if (m_SearchTextField != null)
{
m_SearchTextField.focusable = true;
m_SearchTextField.RegisterCallback<InputEvent>(OnSearchTextFieldTextChanged, TrickleDown.TrickleDown);
m_SearchTextInput = m_SearchTextField.Q(TextInputBaseField<string>.textInputUssName);
m_SearchTextInput.RegisterCallback<KeyDownEvent>(OnSearchTextFieldKeyDown, TrickleDown.TrickleDown);
}
m_AutoCompleteLabel = this.Q<Label>(k_WindowAutoCompleteLabelName);
Resizer = this.Q(k_WindowResizerName);
m_ContentContainer = this.Q("unity-content-container");
m_ConfirmButton = this.Q<Button>("confirmButton");
#if UNITY_2019_3_OR_NEWER
m_ConfirmButton.clicked += OnConfirmMultiselect;
#else
m_ConfirmButton.clickable.clicked += OnConfirmMultiselect;
#endif
RegisterCallback<AttachToPanelEvent>(OnEnterPanel);
RegisterCallback<DetachFromPanelEvent>(OnLeavePanel);
// TODO: HACK - ListView's scroll view steals focus using the scheduler.
EditorApplication.update += HackDueToListViewScrollViewStealingFocus;
style.flexGrow = 1;
}
void OnConfirmMultiselect()
{
if (m_MultiSelectSelection.Count == 0)
{
m_SelectionCallback(null);
return;
}
foreach (SearcherItem item in m_MultiSelectSelection)
{
m_SelectionCallback(item);
}
}
void HackDueToListViewScrollViewStealingFocus()
{
m_SearchTextInput?.Focus();
// ReSharper disable once DelegateSubtraction
EditorApplication.update -= HackDueToListViewScrollViewStealingFocus;
}
void OnEnterPanel(AttachToPanelEvent e)
{
RegisterCallback<KeyDownEvent>(OnKeyDown);
}
void OnLeavePanel(DetachFromPanelEvent e)
{
UnregisterCallback<KeyDownEvent>(OnKeyDown);
}
void OnKeyDown(KeyDownEvent e)
{
if (e.keyCode == KeyCode.Escape)
{
CancelSearch();
}
}
void OnListViewSelect(SearcherItem item)
{
if (!m_Searcher.Adapter.MultiSelectEnabled)
{
m_SelectionCallback(item);
}
else
{
ToggleItemForMultiSelect(item, !m_MultiSelectSelection.Contains(item));
}
}
void CancelSearch()
{
OnSearchTextFieldTextChanged(InputEvent.GetPooled(m_Text, string.Empty));
OnListViewSelect(null);
m_AnalyticsDataCallback?.Invoke(new Searcher.AnalyticsEvent(Searcher.AnalyticsEvent.EventType.Cancelled, m_SearchTextField.value));
}
public void Setup(Searcher searcher, Action<SearcherItem> selectionCallback, Action<Searcher.AnalyticsEvent> analyticsDataCallback, Func<IEnumerable<SearcherItem>, string, SearcherItem> searchResultsFilterCallback)
{
m_Searcher = searcher;
m_SelectionCallback = selectionCallback;
m_AnalyticsDataCallback = analyticsDataCallback;
m_SearchResultsFilterCallback = searchResultsFilterCallback;
if (m_Searcher.Adapter.MultiSelectEnabled) {
AddToClassList("searcher__multiselect");
}
if (m_Searcher.Adapter.HasDetailsPanel)
{
m_Searcher.Adapter.InitDetailsPanel(m_DetailsPanel);
m_DetailsPanel.RemoveFromClassList("hidden");
m_DetailsPanel.style.flexGrow = m_Searcher.Adapter.InitialSplitterDetailRatio;
m_SearcherPanel.style.flexGrow = 1;
}
else
{
m_DetailsPanel.AddToClassList("hidden");
var splitter = m_DetailsPanel.parent;
splitter.parent.Insert(0,m_SearcherPanel);
splitter.parent.Insert(1, m_DetailsPanel);
splitter.RemoveFromHierarchy();
}
TitleLabel.text = m_Searcher.Adapter.Title;
if (string.IsNullOrEmpty(TitleLabel.text))
{
TitleLabel.parent.style.visibility = Visibility.Hidden;
TitleLabel.parent.style.position = Position.Absolute;
}
m_Searcher.BuildIndices();
Refresh();
}
void Refresh()
{
var query = m_Text;
m_Results = m_Searcher.Search(query);
GenerateVisibleResults();
// The first item in the results is always the highest scored item.
// We want to scroll to and select this item.
var visibleIndex = -1;
m_SuggestedTerm = string.Empty;
var results = m_Results.ToList();
if (results.Any())
{
SearcherItem scrollToItem = m_SearchResultsFilterCallback?.Invoke(results, query);
if(scrollToItem == null)
scrollToItem = results.First();
visibleIndex = m_VisibleResults.IndexOf(scrollToItem);
// If we're trying to scroll to a result that is not visible in a single category,
// we need to add that result and its hierarchy back to the visible results
// This prevents searcher suggesting a single collapsed category that the user then needs to manually expand regardless
if (visibleIndex == -1 && m_VisibleResults.Count() == 1)
{
SearcherItem currentItemRoot = scrollToItem;
var idSet = new HashSet<SearcherItem>();
while (currentItemRoot.Parent != null)
{
currentItemRoot = currentItemRoot.Parent;
}
idSet.Add(currentItemRoot);
AddResultChildren(currentItemRoot, idSet);
visibleIndex = m_VisibleResults.IndexOf(scrollToItem);
}
var cursorIndex = m_SearchTextField.cursorIndex;
if (query.Length > 0)
{
var strings = scrollToItem.Name.Split(' ');
var wordStartIndex = cursorIndex == 0 ? 0 : query.LastIndexOf(' ', cursorIndex - 1) + 1;
var word = query.Substring(wordStartIndex, cursorIndex - wordStartIndex);
if (word.Length > 0)
foreach (var t in strings)
{
if (t.StartsWith(word, StringComparison.OrdinalIgnoreCase))
{
m_SuggestedTerm = t;
break;
}
}
}
}
m_ListView.itemsSource = m_VisibleResults;
m_ListView.makeItem = MakeItem;
RefreshListView();
SetSelectedElementInResultsList(visibleIndex);
}
VisualElement MakeItem()
{
VisualElement item = m_Searcher.Adapter.MakeItem();
if (m_Searcher.Adapter.MultiSelectEnabled)
{
var selectionToggle = item.Q<Toggle>("itemToggle");
if (selectionToggle != null)
{
selectionToggle.RegisterValueChangedCallback(changeEvent =>
{
SearcherItem searcherItem = item.userData as SearcherItem;
ToggleItemForMultiSelect(searcherItem, changeEvent.newValue);
});
}
}
return item;
}
void GenerateVisibleResults()
{
if (string.IsNullOrEmpty(m_Text))
{
m_ExpandedResults.Clear();
RemoveChildrenFromResults();
return;
}
RegenerateVisibleResults();
ExpandAllParents();
}
void ExpandAllParents()
{
m_ExpandedResults.Clear();
foreach (var item in m_VisibleResults)
if (item.HasChildren)
m_ExpandedResults.Add(item);
}
void RemoveChildrenFromResults()
{
m_VisibleResults.Clear();
var parents = new HashSet<SearcherItem>();
foreach (var item in m_Results.Where(i => !parents.Contains(i)))
{
var currentParent = item;
while (true)
{
if (currentParent.Parent == null)
{
if (parents.Contains(currentParent))
break;
parents.Add(currentParent);
m_VisibleResults.Add(currentParent);
break;
}
currentParent = currentParent.Parent;
}
}
if (m_Searcher.SortComparison != null)
m_VisibleResults.Sort(m_Searcher.SortComparison);
}
void RegenerateVisibleResults()
{
var idSet = new HashSet<SearcherItem>();
m_VisibleResults.Clear();
foreach (var item in m_Results.Where(item => !idSet.Contains(item)))
{
idSet.Add(item);
m_VisibleResults.Add(item);
var currentParent = item.Parent;
while (currentParent != null)
{
if (!idSet.Contains(currentParent))
{
idSet.Add(currentParent);
m_VisibleResults.Add(currentParent);
}
currentParent = currentParent.Parent;
}
AddResultChildren(item, idSet);
}
var comparison = m_Searcher.SortComparison ?? ((i1, i2) =>
{
var result = i1.Database.Id - i2.Database.Id;
return result != 0 ? result : i1.Id - i2.Id;
});
m_VisibleResults.Sort(comparison);
}
void AddResultChildren(SearcherItem item, ISet<SearcherItem> idSet)
{
if (!item.HasChildren)
return;
if (m_Searcher.Adapter.AddAllChildResults)
{
//add all children results for current search term
// eg "Book" will show both "Cook Book" and "Cooking" as children
foreach (var child in item.Children)
{
if (!idSet.Contains(child))
{
idSet.Add(child);
m_VisibleResults.Add(child);
}
AddResultChildren(child, idSet);
}
}
else
{
foreach (var child in item.Children)
{
//only add child results if the child matches the search term
// eg "Book" will show "Cook Book" but not "Cooking" as a child
if (!m_Results.Contains(child))
continue;
if (!idSet.Contains(child))
{
idSet.Add(child);
m_VisibleResults.Add(child);
}
AddResultChildren(child, idSet);
}
}
}
bool HasChildResult(SearcherItem item)
{
if (m_Results.Contains(item))
return true;
foreach (var child in item.Children)
{
if (HasChildResult(child))
return true;
}
return false;
}
ItemExpanderState GetExpanderState(int index)
{
var item = m_VisibleResults[index];
foreach (var child in item.Children)
{
if (!m_VisibleResults.Contains(child) && !HasChildResult(child))
continue;
return m_ExpandedResults.Contains(item) ? ItemExpanderState.Expanded : ItemExpanderState.Collapsed;
}
return item.Children.Count != 0 ? ItemExpanderState.Collapsed : ItemExpanderState.Hidden;
}
void Bind(VisualElement target, int index)
{
var item = m_VisibleResults[index];
var expanderState = GetExpanderState(index);
var expander = m_Searcher.Adapter.Bind(target, item, expanderState, m_Text);
var selectionToggle = target.Q<Toggle>("itemToggle");
if (selectionToggle != null)
{
selectionToggle.SetValueWithoutNotify(m_MultiSelectSelection.Contains(item));
m_SearchItemToVisualToggle[item] = selectionToggle;
}
expander.RegisterCallback<MouseDownEvent>(ExpandOrCollapse);
}
void ToggleItemForMultiSelect(SearcherItem item, bool selected)
{
if (selected)
{
m_MultiSelectSelection.Add(item);
} else
{
m_MultiSelectSelection.Remove(item);
}
Toggle toggle;
if (m_SearchItemToVisualToggle.TryGetValue(item, out toggle))
{
toggle.SetValueWithoutNotify(selected);
}
foreach (var child in item.Children)
{
ToggleItemForMultiSelect(child, selected);
}
}
static void GetItemsToHide(SearcherItem parent, ref HashSet<SearcherItem> itemsToHide)
{
if (!parent.HasChildren)
{
itemsToHide.Add(parent);
return;
}
foreach (var child in parent.Children)
{
itemsToHide.Add(child);
GetItemsToHide(child, ref itemsToHide);
}
}
void HideUnexpandedItems()
{
// Hide unexpanded children.
var itemsToHide = new HashSet<SearcherItem>();
foreach (var item in m_VisibleResults)
{
if (m_ExpandedResults.Contains(item))
continue;
if (!item.HasChildren)
continue;
if (itemsToHide.Contains(item))
continue;
// We need to hide its children.
GetItemsToHide(item, ref itemsToHide);
}
foreach (var item in itemsToHide)
m_VisibleResults.Remove(item);
}
void RefreshListView()
{
m_SearchItemToVisualToggle.Clear();
#if UNITY_2021_2_OR_NEWER
m_ListView.Rebuild();
#else
m_ListView.Refresh();
#endif
}
// ReSharper disable once UnusedMember.Local
void RefreshListViewOn()
{
// TODO: Call ListView.Refresh() when it is fixed.
// Need this workaround until then.
// See: https://fogbugz.unity3d.com/f/cases/1027728/
// And: https://gitlab.internal.unity3d.com/upm-packages/editor/com.unity.searcher/issues/9
var scrollView = m_ListView.Q<ScrollView>();
var scroller = scrollView?.Q<Scroller>("VerticalScroller");
if (scroller == null)
return;
var oldValue = scroller.value;
scroller.value = oldValue + 1.0f;
scroller.value = oldValue - 1.0f;
scroller.value = oldValue;
}
void Expand(SearcherItem item)
{
m_ExpandedResults.Add(item);
RegenerateVisibleResults();
HideUnexpandedItems();
RefreshListView();
}
void Collapse(SearcherItem item)
{
// if it's already collapsed or not collapsed
if (!m_ExpandedResults.Remove(item))
{
// this case applies for a left arrow key press
if (item.Parent != null)
SetSelectedElementInResultsList(m_VisibleResults.IndexOf(item.Parent));
// even if it's a root item and has no parents, do nothing more
return;
}
RegenerateVisibleResults();
HideUnexpandedItems();
// TODO: understand what happened
RefreshListView();
// RefreshListViewOn();
}
void ExpandOrCollapse(MouseDownEvent evt)
{
if (!(evt.target is VisualElement expanderLabel))
return;
VisualElement itemElement = expanderLabel.GetFirstAncestorOfType<TemplateContainer>();
if (!(itemElement?.userData is SearcherItem item)
|| !item.HasChildren
|| !expanderLabel.ClassListContains("Expanded") && !expanderLabel.ClassListContains("Collapsed"))
return;
if (!m_ExpandedResults.Contains(item))
Expand(item);
else
Collapse(item);
evt.StopImmediatePropagation();
}
void OnSearchTextFieldTextChanged(InputEvent inputEvent)
{
var text = inputEvent.newData;
if (string.Equals(text, m_Text))
return;
// This is necessary due to OnTextChanged(...) being called after user inputs that have no impact on the text.
// Ex: Moving the caret.
m_Text = text;
// If backspace is pressed and no text remain, clear the suggestion label.
if (string.IsNullOrEmpty(text))
{
this.Q(k_WindowSearchIconName).RemoveFromClassList("Active");
// Display the unfiltered results list.
Refresh();
m_AutoCompleteLabel.text = String.Empty;
m_SuggestedTerm = String.Empty;
SetSelectedElementInResultsList(0);
return;
}
if (!this.Q(k_WindowSearchIconName).ClassListContains("Active"))
this.Q(k_WindowSearchIconName).AddToClassList("Active");
Refresh();
// Calculate the start and end indexes of the word being modified (if any).
var cursorIndex = m_SearchTextField.cursorIndex;
// search toward the beginning of the string starting at the character before the cursor
// +1 because we want the char after a space, or 0 if the search fails
var wordStartIndex = cursorIndex == 0 ? 0 : (text.LastIndexOf(' ', cursorIndex - 1) + 1);
// search toward the end of the string from the cursor index
var wordEndIndex = text.IndexOf(' ', cursorIndex);
if (wordEndIndex == -1) // no space found, assume end of string
wordEndIndex = text.Length;
// Clear the suggestion term if the caret is not within a word (both start and end indexes are equal, ex: (space)caret(space))
// or the user didn't append characters to a word at the end of the query.
if (wordStartIndex == wordEndIndex || wordEndIndex < text.Length)
{
m_AutoCompleteLabel.text = string.Empty;
m_SuggestedTerm = string.Empty;
return;
}
var word = text.Substring(wordStartIndex, wordEndIndex - wordStartIndex);
if (!string.IsNullOrEmpty(m_SuggestedTerm))
{
var wordSuggestion =
word + m_SuggestedTerm.Substring(word.Length, m_SuggestedTerm.Length - word.Length);
text = text.Remove(wordStartIndex, word.Length);
text = text.Insert(wordStartIndex, wordSuggestion);
m_AutoCompleteLabel.text = text;
}
else
{
m_AutoCompleteLabel.text = String.Empty;
}
}
void OnSearchTextFieldKeyDown(KeyDownEvent keyDownEvent)
{
// First, check if we cancelled the search.
if (keyDownEvent.keyCode == KeyCode.Escape)
{
CancelSearch();
return;
}
// For some reason the KeyDown event is raised twice when entering a character.
// As such, we ignore one of the duplicate event.
// This workaround was recommended by the Editor team. The cause of the issue relates to how IMGUI works
// and a fix was not in the works at the moment of this writing.
if (keyDownEvent.character == k_TabCharacter)
{
// Prevent switching focus to another visual element.
keyDownEvent.PreventDefault();
return;
}
// If Tab is pressed, complete the query with the suggested term.
if (keyDownEvent.keyCode == KeyCode.Tab)
{
// Used to prevent the TAB input from executing it's default behavior. We're hijacking it for auto-completion.
keyDownEvent.PreventDefault();
if (!string.IsNullOrEmpty(m_SuggestedTerm))
{
SelectAndReplaceCurrentWord();
m_AutoCompleteLabel.text = string.Empty;
// TODO: Revisit, we shouldn't need to do this here.
m_Text = m_SearchTextField.text;
Refresh();
m_SuggestedTerm = string.Empty;
}
}
else
{
SetSelectedElementInResultsList(keyDownEvent);
}
}
void SelectAndReplaceCurrentWord()
{
var s = m_SearchTextField.value;
var lastWordIndex = s.LastIndexOf(' ');
lastWordIndex++;
var newText = s.Substring(0, lastWordIndex) + m_SuggestedTerm;
// Wait for SelectRange api to reach trunk
//#if UNITY_2018_3_OR_NEWER
// m_SearchTextField.value = newText;
// m_SearchTextField.SelectRange(m_SearchTextField.value.Length, m_SearchTextField.value.Length);
//#else
// HACK - relies on the textfield moving the caret when being assigned a value and skipping
// all low surrogate characters
var magicMoveCursorToEndString = new string('\uDC00', newText.Length);
m_SearchTextField.value = magicMoveCursorToEndString;
m_SearchTextField.value = newText;
//#endif
}
void SetSelectedElementInResultsList(KeyDownEvent keyDownEvent)
{
int index;
switch (keyDownEvent.keyCode)
{
case KeyCode.Escape:
OnListViewSelect(null);
m_AnalyticsDataCallback?.Invoke(new Searcher.AnalyticsEvent(Searcher.AnalyticsEvent.EventType.Cancelled, m_SearchTextField.value));
break;
case KeyCode.Return:
case KeyCode.KeypadEnter:
if (m_ListView.selectedIndex != -1)
{
OnListViewSelect((SearcherItem)m_ListView.selectedItem);
m_AnalyticsDataCallback?.Invoke(new Searcher.AnalyticsEvent(Searcher.AnalyticsEvent.EventType.Picked, m_SearchTextField.value));
}
else
{
OnListViewSelect(null);
m_AnalyticsDataCallback?.Invoke(new Searcher.AnalyticsEvent(Searcher.AnalyticsEvent.EventType.Cancelled, m_SearchTextField.value));
}
break;
case KeyCode.LeftArrow:
index = m_ListView.selectedIndex;
if (index >= 0 && index < m_ListView.itemsSource.Count)
Collapse(m_ListView.selectedItem as SearcherItem);
break;
case KeyCode.RightArrow:
index = m_ListView.selectedIndex;
if (index >= 0 && index < m_ListView.itemsSource.Count)
Expand(m_ListView.selectedItem as SearcherItem);
break;
// Fixes bug: https://fogbugz.unity3d.com/f/cases/1358016/
case KeyCode.UpArrow:
case KeyCode.PageUp:
if (m_ListView.selectedIndex > 0)
SetSelectedElementInResultsList(m_ListView.selectedIndex - 1);
break;
case KeyCode.DownArrow:
case KeyCode.PageDown:
if (m_ListView.selectedIndex < 0)
SetSelectedElementInResultsList(0);
else
SetSelectedElementInResultsList(m_ListView.selectedIndex + 1);
break;
}
}
void SetSelectedElementInResultsList(int selectedIndex)
{
var newIndex = selectedIndex >= 0 && selectedIndex < m_VisibleResults.Count ? selectedIndex : -1;
if (newIndex < 0)
return;
m_ListView.selectedIndex = newIndex;
m_ListView.ScrollToItem(m_ListView.selectedIndex);
}
}
}