This commit is contained in:
2021-06-13 10:28:03 +02:00
parent eb70603c85
commit df2d24cbd3
7487 changed files with 943244 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
using UnityEngine;
[assembly: InternalsVisibleTo("Unity.Searcher.EditorSamples")]

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,231 @@
#windowMainVisualContainer {
flex: 1;
flex-direction: row;
background-color: #272727;
}
#windowMainVisualContainer * {
-unity-font: resource("FlatSkin/Font/Roboto-Regular");
}
#windowResizer {
position: absolute;
height: 20px;
width: 20px;
bottom: 0;
right: 0;
padding-left: 10px;
padding-top: 14px;
cursor: resize-up-left;
}
#splitter
{
flex-direction:row;
position: absolute;
right:0;
top:0;
left:0;
bottom:0;
}
#windowResizerIcon {
flex: 1;
background-image:resource("WindowBottomResize");
cursor: resize-up-left;
}
#searcherVisualContainer {
flex: 1;
}
#windowTitleLabel {
font-size: 14px;
color: #FFFFFF;
margin-top: 5px;
margin-left: 6px;
}
#windowSearchBoxVisualContainer {
height: 21px;
background-color: #454545;
margin-right: 3px;
margin-left: 5px;
margin-top: 0;
margin-bottom: 6px;
border-radius: 6px;
border-color: #323232;
border-width: 1px;
}
#searchIcon {
width: 32px;
height: 16px;
background-image:resource("FlatSkin/SearchSmallDownOff");
top: 2px;
left: 4px;
margin-right: 2px;
}
#searchIcon.Active {
background-image:resource("FlatSkin/SearchSmallDownOn");
}
#autoCompleteLabel {
position: absolute;
background-image: none;
top: 2px;
left: 36px;
font-size: 12px;
color: #828282;
-unity-text-align: middle-left;
}
#searchBox {
background-image: none;
position: absolute;
left: 30px;
right: 0;
bottom: 0;
margin-bottom: 2px;
margin-top: 2px;
font-size: 12px;
color: #FFFFFF;
border-color: rgba(0, 0, 0, 0);
background-color: rgba(0, 0, 0, 0);
}
#searchBox .unity-base-field__input {
top: 2px;
font-size: 12px;
-unity-text-align: middle-left;
color: #FFFFFF;
background-image: none;
border-color: rgba(0, 0, 0, 0);
background-color: rgba(0, 0, 0, 0);
}
#windowSelectionVisualContainer {
flex-direction: row;
flex: 1;
}
.unity-list-view.focusableScrollView {
flex: 1;
}
.unity-list-view {
background-color: #383838;
--unity-item-height: 18;
}
.unity-list-view > * {
left: 15px;
}
.unity-list-view > * #labelsContainer {
flex-direction: row;
flex: 1;
left: 8px;
top: -2px;
}
.unity-list-view > * #labelsContainer > .unity-label {
margin-left: 0;
margin-right: 0;
padding-left: 0;
padding-right: 0;
color: #C4C4C4;
font-size: 12px;
}
.unity-list-view > * > .unity-label {
flex: 1;
}
#smartSearchItem > #itemMainVisualContainer > #labelsContainer > .unity-label.Highlighted {
color: #FFCD62;
}
.unity-list-view > * > #smartSearchItem:hover * {
background-color: #424242;
}
.unity-list-view > * > #smartSearchItem.unity-list-view__item--selected * {
background-color: #2B5D87;
}
#smartSearchItem.unity-list-view__item--selected > #itemMainVisualContainer > #labelsContainer > .unity-label {
color : #FFFFFF;
}
#smartSearchItem.unity-list-view__item--selected > #itemMainVisualContainer > #labelsContainer > .unity-label.Highlighted {
color: #FFCD00;
}
.unity-list-view > * #itemChildExpander {
width: 18px;
height: 18px;
}
.unity-list-view > * #itemChildExpander > #expanderIcon {
width: 13px;
height: 13px;
top: 1px;
left: 10px;
}
#smartSearchItem > * > #itemChildExpander > #expanderIcon.Collapsed {
background-image: resource("Builtin Skins/DarkSkin/Images/IN foldout.png");
}
#smartSearchItem > * > #itemChildExpander > #expanderIcon.Expanded {
background-image: resource("Builtin Skins/DarkSkin/Images/IN foldout on.png");
}
#smartSearchItem.unity-list-view__item--selected > * > #itemChildExpander > #expanderIcon.Collapsed {
background-image: resource("Builtin Skins/DarkSkin/Images/IN foldout act.png");
}
#smartSearchItem.unity-list-view__item--selected > * > #itemChildExpander > #expanderIcon.Expanded {
background-image: resource("Builtin Skins/DarkSkin/Images/IN foldout act on.png");
}
.unity-list-view > * #itemMainVisualContainer {
flex-direction: row;
flex: 1;
}
.unity-list-view > *.unity-list-view__item--selected #textLabel {
color: #FFFFFF;
}
.unity-list-view > * #itemMainVisualContainer {
padding-top: 3px;
align-items: center;
}
.unity-list-view > *.Category #textLabel {
font-size: 12px;
color: #676767;
}
#windowDetailsVisualContainer {
flex: 1;
background-color: #383838;
border-left-width: 2px;
border-color: #272727;
padding-left: 6px;
padding-right: 4px;
padding-top: 4px;
}
#windowDetailsVisualContainer.hidden {
display:none;
}
#windowDetailsVisualContainer > .unity-label {
font-size: 11px;
color: #C4C4C4;
white-space: normal;
}

View File

@@ -0,0 +1,11 @@
<UXML xmlns:ui="UnityEngine.UIElements">
<ui:VisualElement name="itemMainVisualContainer">
<Style path="Searcher"/>
<ui:VisualElement name="itemIndent"/>
<ui:VisualElement name="itemChildExpander">
<ui:VisualElement name="expanderIcon"/>
</ui:VisualElement>
<ui:VisualElement name="itemIconVisualElement"/>
<ui:VisualElement name="labelsContainer"/>
</ui:VisualElement>
</UXML>

View File

@@ -0,0 +1,24 @@
<UXML xmlns:ui="UnityEngine.UIElements" xmlns:ed="UnityEditor.Experimental.UIElements" xmlns:lv="UnityEditor.EditorCommon.UIElements.ListView">
<ui:VisualElement name="windowMainVisualContainer">
<Style path="Searcher"/>
<ed:VisualSplitter name="splitter">
<ui:VisualElement name="searcherVisualContainer">
<ui:VisualElement name="windowTitleContainer">
<ui:Label name="windowTitleLabel" focusable="false"/>
</ui:VisualElement>
<ui:VisualElement name="windowSearchBoxVisualContainer">
<ui:VisualElement name="searchIcon"/>
<ui:Label name="autoCompleteLabel"/>
<ui:TextField name="searchBox"/>
</ui:VisualElement>
<ui:VisualElement name="windowSelectionVisualContainer">
<ui:ListView class="focusableScrollView" item-height="25" name="windowResultsScrollView"/>
</ui:VisualElement>
</ui:VisualElement>
<ui:VisualElement name="windowDetailsVisualContainer"/>
</ed:VisualSplitter>
<ui:VisualElement name="windowResizer" focusable="false">
<ui:VisualElement name="windowResizerIcon" focusable="false"/>
</ui:VisualElement>
</ui:VisualElement>
</UXML>

View File

@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
namespace UnityEditor.Searcher
{
[PublicAPI]
public class Searcher
{
public ISearcherAdapter Adapter { get; }
public Comparison<SearcherItem> SortComparison { get; set; }
readonly List<SearcherDatabaseBase> m_Databases;
public Searcher(SearcherDatabaseBase database, string title)
: this(new List<SearcherDatabaseBase> { database }, title, null)
{ }
public Searcher(IEnumerable<SearcherDatabaseBase> databases, string title)
: this(databases, title, null)
{ }
public Searcher(SearcherDatabaseBase database, ISearcherAdapter adapter = null)
: this(new List<SearcherDatabaseBase> { database }, adapter)
{ }
public Searcher(IEnumerable<SearcherDatabaseBase> databases, ISearcherAdapter adapter = null)
: this(databases, string.Empty, adapter)
{ }
Searcher(IEnumerable<SearcherDatabaseBase> databases, string title, ISearcherAdapter adapter)
{
m_Databases = new List<SearcherDatabaseBase>();
var databaseId = 0;
foreach (var database in databases)
{
// This is needed for sorting items between databases.
database.OverwriteId(databaseId);
databaseId++;
m_Databases.Add(database);
}
Adapter = adapter ?? new SearcherAdapter(title);
}
public void BuildIndices()
{
foreach (var database in m_Databases)
{
database.BuildIndex();
}
}
public IEnumerable<SearcherItem> Search(string query)
{
query = query.ToLower();
var results = new List<SearcherItem>();
float maxScore = 0;
foreach (var database in m_Databases)
{
var localResults = database.Search(query, out var localMaxScore);
if (localMaxScore > maxScore)
{
// skip the highest scored item in the local results and
// insert it back as the first item. The first item should always be
// the highest scored item. The order of the other items does not matter
// because they will be reordered to recreate the tree properly.
if (results.Count > 0)
{
// backup previous best result
results.Add(results[0]);
// replace it with the new best result
results[0] = localResults[0];
// add remaining results at the end
results.AddRange(localResults.Skip(1));
}
else // best result will be the first item
results.AddRange(localResults);
maxScore = localMaxScore;
}
else // no new best result just append everything
{
results.AddRange(localResults);
}
}
return results;
}
[PublicAPI]
public class AnalyticsEvent
{
[PublicAPI]
public enum EventType{ Pending, Picked, Cancelled }
public readonly EventType eventType;
public readonly string currentSearchFieldText;
public AnalyticsEvent(EventType eventType, string currentSearchFieldText)
{
this.eventType = eventType;
this.currentSearchFieldText = currentSearchFieldText;
}
}
}
}

View File

@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using UnityEngine;
using UnityEngine.UIElements;
namespace UnityEditor.Searcher
{
public enum ItemExpanderState
{
Hidden,
Collapsed,
Expanded
}
[PublicAPI]
public interface ISearcherAdapter
{
VisualElement MakeItem();
VisualElement Bind(VisualElement target, SearcherItem item, ItemExpanderState expanderState, string text);
string Title { get; }
bool HasDetailsPanel { get; }
bool AddAllChildResults { get; }
float InitialSplitterDetailRatio { get; }
void OnSelectionChanged(IEnumerable<SearcherItem> items);
void InitDetailsPanel(VisualElement detailsPanel);
}
[PublicAPI]
public class SearcherAdapter : ISearcherAdapter
{
const string k_EntryName = "smartSearchItem";
const int k_IndentDepthFactor = 15;
readonly VisualTreeAsset m_DefaultItemTemplate;
public virtual string Title { get; }
public virtual bool HasDetailsPanel => true;
public virtual bool AddAllChildResults => true;
Label m_DetailsLabel;
public virtual float InitialSplitterDetailRatio => 1.0f;
public SearcherAdapter(string title)
{
Title = title;
m_DefaultItemTemplate = Resources.Load<VisualTreeAsset>("SearcherItem");
}
public virtual VisualElement MakeItem()
{
// Create a visual element hierarchy for this search result.
var item = m_DefaultItemTemplate.CloneTree();
return item;
}
public virtual VisualElement Bind(VisualElement element, SearcherItem item, ItemExpanderState expanderState, string query)
{
var indent = element.Q<VisualElement>("itemIndent");
indent.style.width = item.Depth * k_IndentDepthFactor;
var expander = element.Q<VisualElement>("itemChildExpander");
var icon = expander.Query("expanderIcon").First();
icon.ClearClassList();
switch (expanderState)
{
case ItemExpanderState.Expanded:
icon.AddToClassList("Expanded");
break;
case ItemExpanderState.Collapsed:
icon.AddToClassList("Collapsed");
break;
}
var nameLabelsContainer = element.Q<VisualElement>("labelsContainer");
nameLabelsContainer.Clear();
if (string.IsNullOrWhiteSpace(query))
nameLabelsContainer.Add(new Label(item.Name));
else
SearcherHighlighter.HighlightTextBasedOnQuery(nameLabelsContainer, item.Name, query);
element.userData = item;
element.name = k_EntryName;
return expander;
}
public virtual void InitDetailsPanel(VisualElement detailsPanel)
{
m_DetailsLabel = new Label();
detailsPanel.Add(m_DetailsLabel);
}
public virtual void OnSelectionChanged(IEnumerable<SearcherItem> items)
{
if (m_DetailsLabel != null)
{
var itemsList = items.ToList();
m_DetailsLabel.text = itemsList.Any() ? itemsList[0].Help : "No results";
}
}
}
}

View File

@@ -0,0 +1,679 @@
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;
Searcher m_Searcher;
string m_SuggestedTerm;
string m_Text = string.Empty;
Action<SearcherItem> m_SelectionCallback;
Action<Searcher.AnalyticsEvent> m_AnalyticsDataCallback;
ListView m_ListView;
TextField m_SearchTextField;
VisualElement m_SearchTextInput;
VisualElement m_DetailsPanel;
VisualElement m_SearcherPanel;
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_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 => m_SelectionCallback((SearcherItem)obj.FirstOrDefault());
m_ListView.onSelectionChange += selectedItems => m_Searcher.Adapter.OnSelectionChanged(selectedItems.OfType<SearcherItem>().ToList());
#else
m_ListView.onItemChosen += obj => m_SelectionCallback((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);
m_SearchTextInput = m_SearchTextField.Q(TextInputBaseField<string>.textInputUssName);
m_SearchTextInput.RegisterCallback<KeyDownEvent>(OnSearchTextFieldKeyDown);
}
m_AutoCompleteLabel = this.Q<Label>(k_WindowAutoCompleteLabelName);
Resizer = this.Q(k_WindowResizerName);
RegisterCallback<AttachToPanelEvent>(OnEnterPanel);
RegisterCallback<DetachFromPanelEvent>(OnLeavePanel);
// TODO: HACK - ListView's scroll view steals focus using the scheduler.
EditorApplication.update += HackDueToListViewScrollViewStealingFocus;
style.flexGrow = 1;
}
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 CancelSearch()
{
OnSearchTextFieldTextChanged(InputEvent.GetPooled(m_Text, string.Empty));
m_SelectionCallback(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)
{
m_Searcher = searcher;
m_SelectionCallback = selectionCallback;
m_AnalyticsDataCallback = analyticsDataCallback;
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())
{
var scrollToItem = results.First();
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 = m_Searcher.Adapter.MakeItem;
m_ListView.Refresh();
SetSelectedElementInResultsList(visibleIndex);
}
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 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);
expander.RegisterCallback<MouseDownEvent>(ExpandOrCollapse);
}
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);
}
// 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();
m_ListView.Refresh();
}
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
m_ListView.Refresh();
// 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:
m_SelectionCallback(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)
{
m_SelectionCallback((SearcherItem)m_ListView.selectedItem);
m_AnalyticsDataCallback?.Invoke(new Searcher.AnalyticsEvent(Searcher.AnalyticsEvent.EventType.Picked, m_SearchTextField.value));
}
else
{
m_SelectionCallback(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;
case KeyCode.UpArrow:
case KeyCode.DownArrow:
case KeyCode.PageUp:
case KeyCode.PageDown:
index = m_ListView.selectedIndex;
if (index >= 0 && index < m_ListView.itemsSource.Count)
m_ListView.OnKeyDown(keyDownEvent);
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);
}
}
}

View File

@@ -0,0 +1,393 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using JetBrains.Annotations;
using UnityEngine;
namespace UnityEditor.Searcher
{
[PublicAPI]
public class SearcherDatabase : SearcherDatabaseBase
{
Dictionary<string, IReadOnlyList<ValueTuple<string, float>>> m_Index = new Dictionary<string, IReadOnlyList<ValueTuple<string, float>>>();
class Result
{
public SearcherItem item;
public float maxScore;
}
const bool k_IsParallel = true;
public Func<string, SearcherItem, bool> MatchFilter { get; set; }
public static SearcherDatabase Create(
List<SearcherItem> items,
string databaseDirectory,
bool serializeToFile = true
)
{
if (serializeToFile && databaseDirectory != null && !Directory.Exists(databaseDirectory))
Directory.CreateDirectory(databaseDirectory);
var database = new SearcherDatabase(databaseDirectory, items);
if (serializeToFile)
database.SerializeToFile();
database.BuildIndex();
return database;
}
public static SearcherDatabase Load(string databaseDirectory)
{
if (!Directory.Exists(databaseDirectory))
throw new InvalidOperationException("databaseDirectory not found.");
var database = new SearcherDatabase(databaseDirectory, null);
database.LoadFromFile();
database.BuildIndex();
return database;
}
public SearcherDatabase(IReadOnlyCollection<SearcherItem> db)
: this("", db)
{
}
SearcherDatabase(string databaseDirectory, IReadOnlyCollection<SearcherItem> db)
: base(databaseDirectory)
{
m_ItemList = new List<SearcherItem>();
var nextId = 0;
if (db != null)
foreach (var item in db)
AddItemToIndex(item, ref nextId, null);
}
public override List<SearcherItem> Search(string query, out float localMaxScore)
{
// Match assumes the query is trimmed
query = query.Trim(' ', '\t');
localMaxScore = 0;
if (string.IsNullOrWhiteSpace(query))
{
if (MatchFilter == null)
return m_ItemList;
// ReSharper disable once RedundantLogicalConditionalExpressionOperand
if (k_IsParallel && m_ItemList.Count > 100)
return FilterMultiThreaded(query);
return FilterSingleThreaded(query);
}
var finalResults = new List<SearcherItem> { null };
var max = new Result();
var tokenizedQuery = new List<string>();
foreach (var token in Tokenize(query))
{
tokenizedQuery.Add(token.Trim().ToLower());
}
// ReSharper disable once RedundantLogicalConditionalExpressionOperand
if (k_IsParallel && m_ItemList.Count > 100)
SearchMultithreaded(query, tokenizedQuery, max, finalResults);
else
SearchSingleThreaded(query, tokenizedQuery, max, finalResults);
localMaxScore = max.maxScore;
if (max.item != null)
finalResults[0] = max.item;
else
finalResults.RemoveAt(0);
return finalResults;
}
protected virtual bool Match(string query, IReadOnlyList<string> tokenizedQuery, SearcherItem item, out float score)
{
var filter = MatchFilter?.Invoke(query, item) ?? true;
return Match(tokenizedQuery, item.Path, out score) && filter;
}
List<SearcherItem> FilterSingleThreaded(string query)
{
var result = new List<SearcherItem>();
foreach (var searcherItem in m_ItemList)
{
if (!MatchFilter.Invoke(query, searcherItem))
continue;
result.Add(searcherItem);
}
return result;
}
List<SearcherItem> FilterMultiThreaded(string query)
{
var result = new List<SearcherItem>();
var count = Environment.ProcessorCount;
var tasks = new Task[count];
var lists = new List<SearcherItem>[count];
var itemsPerTask = (int)Math.Ceiling(m_ItemList.Count / (float)count);
for (var i = 0; i < count; i++)
{
var i1 = i;
tasks[i] = Task.Run(() =>
{
lists[i1] = new List<SearcherItem>();
for (var j = 0; j < itemsPerTask; j++)
{
var index = j + itemsPerTask * i1;
if (index >= m_ItemList.Count)
break;
var item = m_ItemList[index];
if (!MatchFilter.Invoke(query, item))
continue;
lists[i1].Add(item);
}
});
}
Task.WaitAll(tasks);
for (var i = 0; i < count; i++)
{
result.AddRange(lists[i]);
}
return result;
}
readonly float k_ScoreCutOff = 0.33f;
void SearchSingleThreaded(string query, IReadOnlyList<string> tokenizedQuery, Result max, ICollection<SearcherItem> finalResults)
{
List<Result> results = new List<Result>();
foreach (var item in m_ItemList)
{
float score = 0;
if (query.Length == 0 || Match(query, tokenizedQuery, item, out score))
{
if (score > max.maxScore)
{
max.item = item;
max.maxScore = score;
}
results.Add(new Result() { item = item, maxScore = score});
}
}
PostprocessResults(results, finalResults, max);
}
void SearchMultithreaded(string query, IReadOnlyList<string> tokenizedQuery, Result max, List<SearcherItem> finalResults)
{
var count = Environment.ProcessorCount;
var tasks = new Task[count];
var localResults = new Result[count];
var queue = new ConcurrentQueue<Result>();
var itemsPerTask = (int)Math.Ceiling(m_ItemList.Count / (float)count);
for (var i = 0; i < count; i++)
{
var i1 = i;
localResults[i1] = new Result();
tasks[i] = Task.Run(() =>
{
var result = localResults[i1];
for (var j = 0; j < itemsPerTask; j++)
{
var index = j + itemsPerTask * i1;
if (index >= m_ItemList.Count)
break;
var item = m_ItemList[index];
float score = 0;
if (query.Length == 0 || Match(query, tokenizedQuery, item, out score))
{
if (score > result.maxScore)
{
result.maxScore = score;
result.item = item;
}
queue.Enqueue(new Result { item = item, maxScore = score });
}
}
});
}
Task.WaitAll(tasks);
for (var i = 0; i < count; i++)
{
if (localResults[i].maxScore > max.maxScore)
{
max.maxScore = localResults[i].maxScore;
max.item = localResults[i].item;
}
}
PostprocessResults(queue, finalResults, max);
}
void PostprocessResults(IEnumerable<Result> results, ICollection<SearcherItem> items, Result max)
{
foreach (var result in results)
{
var normalizedScore = result.maxScore / max.maxScore;
if (result.item != null && result.item != max.item && normalizedScore > k_ScoreCutOff)
{
items.Add(result.item);
}
}
}
public override void BuildIndex()
{
m_Index.Clear();
foreach (var item in m_ItemList)
{
if (!m_Index.ContainsKey(item.Path))
{
List<ValueTuple<string, float>> terms = new List<ValueTuple<string, float>>();
// If the item uses synonyms to return results for similar words/phrases, add them to the search terms
IList<string> tokens = null;
if (item.Synonyms == null)
tokens = Tokenize(item.Name);
else
tokens = Tokenize(string.Format("{0} {1}", item.Name, string.Join(" ", item.Synonyms)));
string tokenSuite = "";
foreach (var token in tokens)
{
var t = token.ToLower();
if (t.Length > 1)
{
terms.Add(new ValueTuple<string, float>(t, 0.8f));
}
if (tokenSuite.Length > 0)
{
tokenSuite += " " + t;
terms.Add(new ValueTuple<string, float>(tokenSuite, 1f));
}
else
{
tokenSuite = t;
}
}
// Add a term containing all the uppercase letters (CamelCase World BBox => CCWBB)
var initialList = Regex.Split(item.Name, @"\P{Lu}+");
var initials = string.Concat(initialList).Trim();
if (!string.IsNullOrEmpty(initials))
terms.Add(new ValueTuple<string, float>(initials.ToLower(), 0.5f));
m_Index.Add(item.Path, terms);
}
}
}
static IList<string> Tokenize(string s)
{
var knownTokens = new HashSet<string>();
var tokens = new List<string>();
// Split on word boundaries
foreach (var t in Regex.Split(s, @"\W"))
{
// Split camel case words
var tt = Regex.Split(t, @"(\p{Lu}+\P{Lu}*)");
foreach (var ttt in tt)
{
var tttt = ttt.Trim();
if (!string.IsNullOrEmpty(tttt) && !knownTokens.Contains(tttt))
{
knownTokens.Add(tttt);
tokens.Add(tttt);
}
}
}
return tokens;
}
bool Match(IReadOnlyList<string> tokenizedQuery, string itemPath, out float score)
{
itemPath = itemPath.Trim();
if (itemPath == "")
{
if (tokenizedQuery.Count == 0)
{
score = 1;
return true;
}
else
{
score = 0;
return false;
}
}
IReadOnlyList<ValueTuple<string, float>> indexTerms;
if (!m_Index.TryGetValue(itemPath, out indexTerms))
{
score = 0;
return false;
}
float maxScore = 0.0f;
foreach (var t in indexTerms)
{
float scoreForTerm = 0f;
var querySuite = "";
var querySuiteFactor = 1.25f;
foreach (var q in tokenizedQuery)
{
if (t.Item1.StartsWith(q))
{
scoreForTerm += t.Item2 * q.Length / t.Item1.Length;
}
if (querySuite.Length > 0)
{
querySuite += " " + q;
if (t.Item1.StartsWith(querySuite))
{
scoreForTerm += t.Item2 * querySuiteFactor * querySuite.Length / t.Item1.Length;
}
}
else
{
querySuite = q;
}
querySuiteFactor *= querySuiteFactor;
}
maxScore = Mathf.Max(maxScore, scoreForTerm);
}
score = maxScore;
return score > 0;
}
}
}

View File

@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.IO;
using JetBrains.Annotations;
using UnityEngine;
namespace UnityEditor.Searcher
{
[PublicAPI]
public abstract class SearcherDatabaseBase
{
protected const string k_SerializedJsonFile = "/SerializedDatabase.json";
public string DatabaseDirectory { get; set; }
public IList<SearcherItem> ItemList => m_ItemList;
// ReSharper disable once Unity.RedundantSerializeFieldAttribute
[SerializeField]
protected List<SearcherItem> m_ItemList;
protected SearcherDatabaseBase(string databaseDirectory)
{
DatabaseDirectory = databaseDirectory;
}
public virtual void BuildIndex() { }
public abstract List<SearcherItem> Search(string query, out float localMaxScore);
internal void OverwriteId(int newId)
{
Id = newId;
}
internal int Id { get; private set; }
protected void LoadFromFile()
{
var reader = new StreamReader(DatabaseDirectory + k_SerializedJsonFile);
var serializedData = reader.ReadToEnd();
reader.Close();
EditorJsonUtility.FromJsonOverwrite(serializedData, this);
foreach (var item in m_ItemList)
{
item.OverwriteDatabase(this);
item.ReInitAfterLoadFromFile();
}
}
protected void SerializeToFile()
{
if (DatabaseDirectory == null)
return;
var serializedData = EditorJsonUtility.ToJson(this, true);
var writer = new StreamWriter(DatabaseDirectory + k_SerializedJsonFile, false);
writer.Write(serializedData);
writer.Close();
}
protected void AddItemToIndex(SearcherItem item, ref int lastId, Action<SearcherItem> action)
{
m_ItemList.Insert(lastId, item);
// We can only set the id here as we only know the final index of the item here.
item.OverwriteId(lastId);
item.GeneratePath();
action?.Invoke(item);
lastId++;
// This is used for sorting results between databases.
item.OverwriteDatabase(this);
if (!item.HasChildren)
return;
var childrenIds = new List<int>();
foreach (SearcherItem child in item.Children)
{
AddItemToIndex(child, ref lastId, action);
childrenIds.Add(child.Id);
}
item.OverwriteChildrenIds(childrenIds);
}
}
}

View File

@@ -0,0 +1,95 @@
using System;
using System.Text.RegularExpressions;
using UnityEngine.UIElements;
namespace UnityEditor.Searcher
{
static class SearcherHighlighter
{
const char k_StartHighlightSeparator = '{';
const char k_EndHighlightSeparator = '}';
const string k_HighlightedStyleClassName = "Highlighted";
public static void HighlightTextBasedOnQuery(VisualElement container, string text, string query)
{
var formattedText = text;
var queryParts = query.Split(new[] {" "}, StringSplitOptions.RemoveEmptyEntries);
var regex = string.Empty;
for (var index = 0; index < queryParts.Length; index++)
{
var queryPart = queryParts[index];
regex += $"({queryPart})";
if (index < queryParts.Length - 1)
regex += "|";
}
var matches = Regex.Matches(formattedText, regex, RegexOptions.IgnoreCase);
foreach (Match match in matches)
{
formattedText = formattedText.Replace(match.Value,
$"{k_StartHighlightSeparator}{match.Value}{k_EndHighlightSeparator}");
}
BuildHighlightLabels(container, formattedText);
}
static void BuildHighlightLabels(VisualElement container, string formattedHighlightText)
{
if (string.IsNullOrEmpty(formattedHighlightText))
return;
var substring = string.Empty;
var highlighting = false;
var skipCount = 0;
foreach (var character in formattedHighlightText.ToCharArray())
{
switch (character)
{
// Skip embedded separators
// Ex:
// Query: middle e
// Text: Middle Eastern
// Formatted Text: {Middl{e}} {E}ast{e}rn
// ^ ^
case k_StartHighlightSeparator when highlighting:
skipCount++;
continue;
case k_StartHighlightSeparator: {
highlighting = true;
if (!string.IsNullOrEmpty(substring))
{
container.Add(new Label(substring));
substring = string.Empty;
}
continue;
}
case k_EndHighlightSeparator when skipCount > 0:
skipCount--;
continue;
case k_EndHighlightSeparator: {
var label = new Label(substring);
label.AddToClassList(k_HighlightedStyleClassName);
container.Add(label);
highlighting = false;
substring = string.Empty;
continue;
}
default:
substring += character;
break;
}
}
if (!string.IsNullOrEmpty(substring))
{
var label = new Label(substring);
if (highlighting)
label.AddToClassList(k_HighlightedStyleClassName);
container.Add(label);
}
}
}
}

View File

@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
using JetBrains.Annotations;
using UnityEngine;
namespace UnityEditor.Searcher
{
[PublicAPI]
[Serializable]
public class SearcherItem
{
[SerializeField] int m_Id;
[SerializeField] List<int> m_ChildrenIds;
[SerializeField] string m_Name;
[SerializeField] string m_Help;
[SerializeField] string[] m_Synonyms;
public int Id => m_Id;
public virtual string Name => m_Name;
public string Path { get; private set; }
public string Help => m_Help;
public string[] Synonyms { get { return m_Synonyms; } set { m_Synonyms = value; } }
public int Depth => Parent?.Depth + 1 ?? 0;
public SearcherItem Parent { get; private set; }
public SearcherDatabaseBase Database { get; private set; }
public List<SearcherItem> Children { get; private set; }
public bool HasChildren => Children.Count > 0;
public SearcherItem(string name, string help = "", List<SearcherItem> children = null)
{
m_Id = -1;
Parent = null;
Database = null;
m_Name = name;
m_Help = help;
Children = new List<SearcherItem>();
if (children == null)
return;
Children = children;
foreach (var child in children)
child.OverwriteParent(this);
}
public void AddChild(SearcherItem child)
{
if (child == null)
throw new ArgumentNullException(nameof(child));
if (Database != null)
throw new InvalidOperationException(
"Cannot add more children to an item that was already used in a database.");
if (Children == null)
Children = new List<SearcherItem>();
Children.Add(child);
child.OverwriteParent(this);
}
internal void OverwriteId(int newId)
{
m_Id = newId;
}
void OverwriteParent(SearcherItem newParent)
{
Parent = newParent;
}
internal void OverwriteDatabase(SearcherDatabaseBase newDatabase)
{
Database = newDatabase;
}
internal void OverwriteChildrenIds(List<int> childrenIds)
{
m_ChildrenIds = childrenIds;
}
internal void GeneratePath()
{
if (Parent != null)
Path = Parent.Path + " ";
else
Path = string.Empty;
Path += Name;
}
internal void ReInitAfterLoadFromFile()
{
if (Children == null)
Children = new List<SearcherItem>();
foreach (var id in m_ChildrenIds)
{
var child = Database.ItemList[id];
Children.Add(child);
child.OverwriteParent(this);
}
GeneratePath();
}
public override string ToString()
{
return $"{nameof(Id)}: {Id}, {nameof(Name)}: {Name}, {nameof(Depth)}: {Depth}";
}
}
}

View File

@@ -0,0 +1,428 @@
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using UnityEngine;
using UnityEngine.UIElements;
namespace UnityEditor.Searcher
{
[PublicAPI]
public class SearcherWindow : EditorWindow
{
[PublicAPI]
public struct Alignment
{
[PublicAPI]
public enum Horizontal { Left = 0, Center, Right }
[PublicAPI]
public enum Vertical { Top = 0, Center, Bottom }
public readonly Vertical vertical;
public readonly Horizontal horizontal;
public Alignment(Vertical v, Horizontal h)
{
vertical = v;
horizontal = h;
}
}
const string k_DatabaseDirectory = "/../Library/Searcher";
static readonly float k_SearcherDefaultWidth = 300;
static readonly float k_DetailsDefaultWidth = 200;
static readonly float k_DefaultHeight = 300;
static readonly Vector2 k_MinSize = new Vector2(300, 150);
static Vector2 s_Size = Vector2.zero;
static IEnumerable<SearcherItem> s_Items;
static Searcher s_Searcher;
static Func<SearcherItem, bool> s_ItemSelectedDelegate;
Action<Searcher.AnalyticsEvent> m_AnalyticsDataDelegate;
SearcherControl m_SearcherControl;
Vector2 m_OriginalMousePos;
Rect m_OriginalWindowPos;
Rect m_NewWindowPos;
bool m_IsMouseDownOnResizer;
bool m_IsMouseDownOnTitle;
Focusable m_FocusedBefore;
static Vector2 Size
{
get
{
if (s_Size == Vector2.zero)
{
s_Size = s_Searcher != null && s_Searcher.Adapter.HasDetailsPanel
? new Vector2(k_SearcherDefaultWidth + k_DetailsDefaultWidth, k_DefaultHeight)
: new Vector2(k_SearcherDefaultWidth, k_DefaultHeight);
}
return s_Size;
}
set => s_Size = value;
}
public static void Show(
EditorWindow host,
IList<SearcherItem> items,
string title,
Func<SearcherItem, bool> itemSelectedDelegate,
Vector2 displayPosition,
Alignment align = default)
{
Show(host, items, title, Application.dataPath + k_DatabaseDirectory, itemSelectedDelegate, displayPosition, align);
}
public static void Show(
EditorWindow host,
IList<SearcherItem> items,
ISearcherAdapter adapter,
Func<SearcherItem, bool> itemSelectedDelegate,
Vector2 displayPosition,
Action<Searcher.AnalyticsEvent> analyticsDataDelegate,
Alignment align = default)
{
Show(host, items, adapter, Application.dataPath + k_DatabaseDirectory, itemSelectedDelegate,
displayPosition, analyticsDataDelegate, align);
}
public static void Show(
EditorWindow host,
IList<SearcherItem> items,
string title,
string directoryPath,
Func<SearcherItem, bool> itemSelectedDelegate,
Vector2 displayPosition,
Alignment align = default)
{
s_Items = items;
var databaseDir = directoryPath;
var database = SearcherDatabase.Create(s_Items.ToList(), databaseDir);
s_Searcher = new Searcher(database, title);
Show(host, s_Searcher, itemSelectedDelegate, displayPosition, null, align);
}
public static void Show(
EditorWindow host,
IEnumerable<SearcherItem> items,
ISearcherAdapter adapter,
string directoryPath,
Func<SearcherItem, bool> itemSelectedDelegate,
Vector2 displayPosition,
Action<Searcher.AnalyticsEvent> analyticsDataDelegate,
Alignment align = default)
{
s_Items = items;
var databaseDir = directoryPath;
var database = SearcherDatabase.Create(s_Items.ToList(), databaseDir);
s_Searcher = new Searcher(database, adapter);
Show(host, s_Searcher, itemSelectedDelegate, displayPosition, analyticsDataDelegate, align);
}
public static void Show(
EditorWindow host,
Searcher searcher,
Func<SearcherItem, bool> itemSelectedDelegate,
Vector2 displayPosition,
Action<Searcher.AnalyticsEvent> analyticsDataDelegate,
Alignment align = default)
{
var position = GetPosition(host, displayPosition, align);
var rect = new Rect(GetPositionWithAlignment(position + host.position.position, Size, align), Size);
Show(host, searcher, itemSelectedDelegate, analyticsDataDelegate, rect);
}
public static void Show(
EditorWindow host,
Searcher searcher,
Func<SearcherItem, bool> itemSelectedDelegate,
Action<Searcher.AnalyticsEvent> analyticsDataDelegate,
Rect rect)
{
s_Searcher = searcher;
s_ItemSelectedDelegate = itemSelectedDelegate;
var window = CreateInstance<SearcherWindow>();
window.m_AnalyticsDataDelegate = analyticsDataDelegate;
window.position = rect;
window.ShowPopup();
window.Focus();
}
public static Vector2 GetPositionWithAlignment(Vector2 pos, Vector2 size, Alignment align)
{
var x = pos.x;
var y = pos.y;
switch (align.horizontal)
{
case Alignment.Horizontal.Center:
x -= size.x / 2;
break;
case Alignment.Horizontal.Right:
x -= size.x;
break;
}
switch (align.vertical)
{
case Alignment.Vertical.Center:
y -= size.y / 2;
break;
case Alignment.Vertical.Bottom:
y -= size.y;
break;
}
return new Vector2(x, y);
}
static Vector2 GetPosition(EditorWindow host, Vector2 displayPosition, Alignment align)
{
var x = displayPosition.x;
var y = displayPosition.y;
// Searcher overlaps with the right boundary.
if (x + Size.x >= host.position.size.x)
{
switch (align.horizontal)
{
case Alignment.Horizontal.Center:
x -= Size.x / 2;
break;
case Alignment.Horizontal.Right:
x -= Size.x;
break;
}
}
// The displayPosition should be in window world space but the
// EditorWindow.position is actually the rootVisualElement
// rectangle, not including the tabs area. So we need to do a
// small correction here.
y -= host.rootVisualElement.resolvedStyle.top;
// Searcher overlaps with the bottom boundary.
if (y + Size.y >= host.position.size.y)
{
switch (align.vertical)
{
case Alignment.Vertical.Center:
y -= Size.y / 2;
break;
case Alignment.Vertical.Bottom:
y -= Size.y;
break;
}
}
return new Vector2(x, y);
}
void OnEnable()
{
m_SearcherControl = new SearcherControl();
m_SearcherControl.Setup(s_Searcher, SelectionCallback, OnAnalyticsDataCallback);
m_SearcherControl.TitleLabel.RegisterCallback<MouseDownEvent>(OnTitleMouseDown);
m_SearcherControl.TitleLabel.RegisterCallback<MouseUpEvent>(OnTitleMouseUp);
m_SearcherControl.Resizer.RegisterCallback<MouseDownEvent>(OnResizerMouseDown);
m_SearcherControl.Resizer.RegisterCallback<MouseUpEvent>(OnResizerMouseUp);
var root = rootVisualElement;
root.style.flexGrow = 1;
root.Add(m_SearcherControl);
}
void OnDisable()
{
m_SearcherControl.TitleLabel.UnregisterCallback<MouseDownEvent>(OnTitleMouseDown);
m_SearcherControl.TitleLabel.UnregisterCallback<MouseUpEvent>(OnTitleMouseUp);
m_SearcherControl.Resizer.UnregisterCallback<MouseDownEvent>(OnResizerMouseDown);
m_SearcherControl.Resizer.UnregisterCallback<MouseUpEvent>(OnResizerMouseUp);
}
void OnTitleMouseDown(MouseDownEvent evt)
{
if (evt.button != (int)MouseButton.LeftMouse)
return;
m_IsMouseDownOnTitle = true;
m_NewWindowPos = position;
m_OriginalWindowPos = position;
m_OriginalMousePos = evt.mousePosition;
m_FocusedBefore = rootVisualElement.panel.focusController.focusedElement;
m_SearcherControl.TitleLabel.RegisterCallback<MouseMoveEvent>(OnTitleMouseMove);
m_SearcherControl.TitleLabel.RegisterCallback<KeyDownEvent>(OnSearcherKeyDown);
m_SearcherControl.TitleLabel.CaptureMouse();
}
void OnTitleMouseUp(MouseUpEvent evt)
{
if (evt.button != (int)MouseButton.LeftMouse)
return;
if (!m_SearcherControl.TitleLabel.HasMouseCapture())
return;
FinishMove();
}
void FinishMove()
{
m_SearcherControl.TitleLabel.UnregisterCallback<MouseMoveEvent>(OnTitleMouseMove);
m_SearcherControl.TitleLabel.UnregisterCallback<KeyDownEvent>(OnSearcherKeyDown);
m_SearcherControl.TitleLabel.ReleaseMouse();
m_FocusedBefore?.Focus();
m_IsMouseDownOnTitle = false;
}
void OnTitleMouseMove(MouseMoveEvent evt)
{
var delta = evt.mousePosition - m_OriginalMousePos;
// TODO Temporary fix for Visual Scripting 1st drop. Find why position.position is 0,0 on MacOs in MouseMoveEvent
// Bug occurs with Unity 2019.2.0a13
#if UNITY_EDITOR_OSX
m_NewWindowPos = new Rect(m_NewWindowPos.position + delta, position.size);
#else
m_NewWindowPos = new Rect(position.position + delta, position.size);
#endif
Repaint();
}
void OnResizerMouseDown(MouseDownEvent evt)
{
if (evt.button != (int)MouseButton.LeftMouse)
return;
m_IsMouseDownOnResizer = true;
m_NewWindowPos = position;
m_OriginalWindowPos = position;
m_OriginalMousePos = evt.mousePosition;
m_FocusedBefore = rootVisualElement.panel.focusController.focusedElement;
m_SearcherControl.Resizer.RegisterCallback<MouseMoveEvent>(OnResizerMouseMove);
m_SearcherControl.Resizer.RegisterCallback<KeyDownEvent>(OnSearcherKeyDown);
m_SearcherControl.Resizer.CaptureMouse();
}
void OnResizerMouseUp(MouseUpEvent evt)
{
if (evt.button != (int)MouseButton.LeftMouse)
return;
if (!m_SearcherControl.Resizer.HasMouseCapture())
return;
FinishResize();
}
void FinishResize()
{
m_SearcherControl.Resizer.UnregisterCallback<MouseMoveEvent>(OnResizerMouseMove);
m_SearcherControl.Resizer.UnregisterCallback<KeyDownEvent>(OnSearcherKeyDown);
m_SearcherControl.Resizer.ReleaseMouse();
m_FocusedBefore?.Focus();
m_IsMouseDownOnResizer = false;
}
void OnResizerMouseMove(MouseMoveEvent evt)
{
var delta = evt.mousePosition - m_OriginalMousePos;
Size = m_OriginalWindowPos.size + delta;
Size = new Vector2(Math.Max(k_MinSize.x, Size.x), Math.Max(k_MinSize.y, Size.y));
// TODO Temporary fix for Visual Scripting 1st drop. Find why position.position is 0,0 on MacOs in MouseMoveEvent
// Bug occurs with Unity 2019.2.0a13
#if UNITY_EDITOR_OSX
m_NewWindowPos = new Rect(m_NewWindowPos.position, Size);
#else
m_NewWindowPos = new Rect(position.position, Size);
#endif
Repaint();
}
void OnSearcherKeyDown(KeyDownEvent evt)
{
if (evt.keyCode == KeyCode.Escape)
{
if (m_IsMouseDownOnTitle)
{
FinishMove();
position = m_OriginalWindowPos;
}
else if (m_IsMouseDownOnResizer)
{
FinishResize();
position = m_OriginalWindowPos;
}
}
}
void OnGUI()
{
if ((m_IsMouseDownOnTitle || m_IsMouseDownOnResizer) && Event.current.type == EventType.Layout)
position = m_NewWindowPos;
}
void SelectionCallback(SearcherItem item)
{
// Don't close the window if a category is selected (only categories/titles have children, node entries are leaf elements)
// We want to prevent collapsing the window due to accidental double-clicks on a title entry, for instance
if (item != null && item.HasChildren)
return;
if (s_ItemSelectedDelegate == null || s_ItemSelectedDelegate(item))
Close();
}
void OnAnalyticsDataCallback(Searcher.AnalyticsEvent item)
{
m_AnalyticsDataDelegate?.Invoke(item);
}
void OnLostFocus()
{
if (m_IsMouseDownOnTitle)
{
FinishMove();
}
else if (m_IsMouseDownOnResizer)
{
FinishResize();
}
// TODO: HACK - ListView's scroll view steals focus using the scheduler.
EditorApplication.update += HackDueToCloseOnLostFocusCrashing;
}
// See: https://fogbugz.unity3d.com/f/cases/1004504/
void HackDueToCloseOnLostFocusCrashing()
{
// Notify user that the searcher action was cancelled.
s_ItemSelectedDelegate?.Invoke(null);
Close();
// ReSharper disable once DelegateSubtraction
EditorApplication.update -= HackDueToCloseOnLostFocusCrashing;
}
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "Unity.Searcher.Editor",
"references": [],
"includePlatforms": [
"Editor"
],
"excludePlatforms": []
}