testss
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using UnityEngine;
|
||||
|
||||
[assembly: InternalsVisibleTo("Unity.Searcher.EditorSamples")]
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 |
@@ -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;
|
||||
}
|
@@ -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>
|
@@ -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>
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Unity.Searcher.Editor",
|
||||
"references": [],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": []
|
||||
}
|
Reference in New Issue
Block a user