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); } } }