Problem
The next version of rubberduck is going to include a pretty nifty “goto anything” / find symbol navigation tool that lets the user enter an identifier name in a combobox:
UI
To achieve the templated dropdown I had to introduce some WPF interop. Here’s the markup:
<UserControl.CommandBindings>
<CommandBinding Command="local:FindSymbolControl.GoCommand"
Executed="CommandBinding_OnExecuted"
CanExecute="CommandBinding_OnCanExecute"/>
</UserControl.CommandBindings>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="32" />
</Grid.ColumnDefinitions>
<ComboBox IsEditable="True"
ItemsSource="{Binding MatchResults}"
SelectedItem="{Binding SelectedItem, UpdateSourceTrigger=PropertyChanged}"
IsTextSearchCaseSensitive="False"
IsTextSearchEnabled="True"
TextSearch.Text="{Binding SearchString, Mode=OneWayToSource, UpdateSourceTrigger=PropertyChanged}"
TextSearch.TextPath="IdentifierName">
<ComboBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ComboBox.ItemsPanel>
<ComboBox.ItemTemplate>
<DataTemplate DataType="local:SearchResult">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Image Height="16" Width="16" Margin="2,0,2,0" Source="{Binding Icon}" />
<TextBlock Margin="2,0,2,0" Text="{Binding IdentifierName}" FontWeight="Bold" MinWidth="160" VerticalAlignment="Center" />
<TextBlock Margin="2,0,2,0" Text="{Binding Location}" VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="1"
Command="local:FindSymbolControl.GoCommand">
<Image Height="16" Source="pack://application:,,,/Rubberduck;component/Resources/arrow.png" />
</Button>
</Grid>
…and the code-behind:
namespace Rubberduck.UI.FindSymbol
{
/// <summary>
/// Interaction logic for FindSymbolControl.xaml
/// </summary>
public partial class FindSymbolControl : UserControl
{
public FindSymbolControl()
{
InitializeComponent();
}
private FindSymbolViewModel ViewModel { get { return (FindSymbolViewModel)DataContext; } }
private static readonly ICommand _goCommand = new RoutedCommand();
public static ICommand GoCommand { get { return _goCommand; } }
private void CommandBinding_OnExecuted(object sender, ExecutedRoutedEventArgs e)
{
ViewModel.Execute();
}
private void CommandBinding_OnCanExecute(object sender, CanExecuteRoutedEventArgs e)
{
if (ViewModel == null)
{
return;
}
e.CanExecute = ViewModel.CanExecute();
e.Handled = true;
}
}
}
The WPF control is embedded inside a WinForms host, which is basically only responsible for setting the ViewModel:
namespace Rubberduck.UI.FindSymbol
{
public partial class FindSymbolDialog : Form
{
public FindSymbolDialog(FindSymbolViewModel viewModel)
: this()
{
findSymbolControl1.DataContext = viewModel;
}
public FindSymbolDialog()
{
InitializeComponent();
}
}
}
Logic
I implemented the logic inside the ViewModel class:
namespace Rubberduck.UI.FindSymbol
{
public class FindSymbolViewModel : INotifyPropertyChanged
{
private static readonly DeclarationType[] ExcludedTypes =
{
DeclarationType.Control,
DeclarationType.ModuleOption
};
public FindSymbolViewModel(IEnumerable<Declaration> declarations, SearchResultIconCache cache)
{
_declarations = declarations;
_cache = cache;
var initialResults = _declarations
.Where(declaration => !ExcludedTypes.Contains(declaration.DeclarationType))
.OrderBy(declaration => declaration.IdentifierName.ToLowerInvariant())
.Select(declaration => new SearchResult(declaration, cache[declaration]))
.ToList();
MatchResults = new ObservableCollection<SearchResult>(initialResults);
}
public event EventHandler<NavigateCodeEventArgs> Navigate;
public bool CanExecute()
{
return _selectedItem != null;
}
public void Execute()
{
OnNavigate();
}
public void OnNavigate()
{
var handler = Navigate;
if (handler != null && _selectedItem != null)
{
var arg = new NavigateCodeEventArgs(_selectedItem.Declaration);
handler(this, arg);
}
}
private readonly IEnumerable<Declaration> _declarations;
private readonly SearchResultIconCache _cache;
private void Search(string value)
{
var lower = value.ToLowerInvariant();
var results = _declarations
.Where(declaration => !ExcludedTypes.Contains(declaration.DeclarationType)
&& (string.IsNullOrEmpty(value) || declaration.IdentifierName.ToLowerInvariant().Contains(lower)))
.OrderBy(declaration => declaration.IdentifierName.ToLowerInvariant())
.Select(declaration => new SearchResult(declaration, _cache[declaration]))
.ToList();
MatchResults = new ObservableCollection<SearchResult>(results);
}
private string _searchString;
public string SearchString
{
get { return _searchString; }
set
{
_searchString = value;
Search(value);
}
}
private SearchResult _selectedItem;
public SearchResult SelectedItem
{
get { return _selectedItem; }
set
{
_selectedItem = value;
OnPropertyChanged();
}
}
private ObservableCollection<SearchResult> _matchResults;
public ObservableCollection<SearchResult> MatchResults
{
get { return _matchResults; }
set { _matchResults = value; OnPropertyChanged(); }
}
public event PropertyChangedEventHandler PropertyChanged;
[NotifyPropertyChangedInvocator]
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
var handler = PropertyChanged;
if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName));
}
}
}
A SearchResult
is simply wrapping up a Declaration
for presentation purposes:
namespace Rubberduck.UI.FindSymbol
{
public class SearchResult
{
private readonly Declaration _declaration;
private readonly BitmapImage _icon;
public SearchResult(Declaration declaration, BitmapImage icon)
{
_declaration = declaration;
_icon = icon;
}
public Declaration Declaration { get { return _declaration; } }
public string IdentifierName { get { return _declaration.IdentifierName; } }
public string Location { get { return _declaration.Scope; } }
public BitmapImage Icon { get { return _icon; } }
}
}
I originally had an IValueConverter
to convert a Declaration
into a BitmapImage
at the XAML level, but that meant creating an instance of a bitmap for every single search result, and felt inefficient.
So I implemented this “cache” instead:
namespace Rubberduck.UI.FindSymbol
{
public class SearchResultIconCache
{
private readonly IDictionary<Tuple<DeclarationType, Accessibility>, BitmapImage> _images;
public SearchResultIconCache()
{
var types = Enum.GetValues(typeof (DeclarationType)).Cast<DeclarationType>();
var accessibilities = Enum.GetValues(typeof (Accessibility)).Cast<Accessibility>();
_images = types.SelectMany(t => accessibilities.Select(a => Tuple.Create(t, a)))
.ToDictionary(key => key, key => new BitmapImage(GetIconUri(key.Item1, key.Item2)));
}
public BitmapImage this[Declaration declaration]
{
get
{
var key = Tuple.Create(declaration.DeclarationType, declaration.Accessibility);
return _images[key];
}
}
private Uri GetIconUri(DeclarationType declarationType, Accessibility accessibility)
{
const string baseUri = @"../../Resources/Microsoft/PNG/";
string path;
switch (declarationType)
{
case DeclarationType.Module:
path = "VSObject_Module.png";
break;
case DeclarationType.Document | DeclarationType.Class:
path = "document.png";
break;
case DeclarationType.UserForm | DeclarationType.Class | DeclarationType.Control:
path = "VSProject_Form.png";
break;
case DeclarationType.Class | DeclarationType.Module:
path = "VSProject_Class.png";
break;
case DeclarationType.Procedure | DeclarationType.Member:
case DeclarationType.Function | DeclarationType.Member:
if (accessibility == Accessibility.Private)
{
path = "VSObject_Method_Private.png";
break;
}
if (accessibility == Accessibility.Friend)
{
path = "VSObject_Method_Friend.png";
break;
}
path = "VSObject_Method.png";
break;
case DeclarationType.PropertyGet | DeclarationType.Property | DeclarationType.Function:
case DeclarationType.PropertyLet | DeclarationType.Property | DeclarationType.Procedure:
case DeclarationType.PropertySet | DeclarationType.Property | DeclarationType.Procedure:
if (accessibility == Accessibility.Private)
{
path = "VSObject_Properties_Private.png";
break;
}
if (accessibility == Accessibility.Friend)
{
path = "VSObject_Properties_Friend.png";
break;
}
path = "VSObject_Properties.png";
break;
case DeclarationType.Parameter:
path = "VSObject_Field_Shortcut.png";
break;
case DeclarationType.Variable:
if (accessibility == Accessibility.Private)
{
path = "VSObject_Field_Private.png";
break;
}
if (accessibility == Accessibility.Friend)
{
path = "VSObject_Field_Friend.png";
break;
}
path = "VSObject_Field.png";
break;
case DeclarationType.Constant:
if (accessibility == Accessibility.Private)
{
path = "VSObject_Constant_Private.png";
break;
}
if (accessibility == Accessibility.Friend)
{
path = "VSObject_Constant_Friend.png";
break;
}
path = "VSObject_Constant.png";
break;
case DeclarationType.Enumeration:
if (accessibility == Accessibility.Private)
{
path = "VSObject_Enum_Private.png";
break;
}
if (accessibility == Accessibility.Friend)
{
path = "VSObject_Enum_Friend.png";
break;
}
path = "VSObject_Enum.png";
break;
case DeclarationType.EnumerationMember | DeclarationType.Constant:
path = "VSObject_EnumItem.png";
break;
case DeclarationType.Event:
if (accessibility == Accessibility.Private)
{
path = "VSObject_Event_Private.png";
break;
}
if (accessibility == Accessibility.Friend)
{
path = "VSObject_Event_Friend.png";
break;
}
path = "VSObject_Event.png";
break;
case DeclarationType.UserDefinedType:
if (accessibility == Accessibility.Private)
{
path = "VSObject_ValueTypePrivate.png";
break;
}
if (accessibility == Accessibility.Friend)
{
path = "VSObject_ValueType_Friend.png";
break;
}
path = "VSObject_ValueType.png";
break;
case DeclarationType.UserDefinedTypeMember | DeclarationType.Variable:
path = "VSObject_Field.png";
break;
case DeclarationType.LibraryProcedure | DeclarationType.Procedure:
case DeclarationType.LibraryFunction | DeclarationType.Function:
path = "VSObject_Method_Shortcut.png";
break;
case DeclarationType.LineLabel:
path = "VSObject_Constant_Shortcut.png";
break;
case DeclarationType.Project:
path = "VSObject_Library.png";
break;
default:
path = "VSObject_Structure.png";
break;
}
return new Uri(baseUri + path, UriKind.Relative);
}
}
}
Usage
The calling code (a context menu button) uses it like this:
private void FindSymbolContextMenuClick(CommandBarButton Ctrl, ref bool CancelDefault)
{
var declarations = _parser.Parse(IDE.ActiveVBProject, this).Declarations;
var vm = new FindSymbolViewModel(declarations.Items.Where(item => !item.IsBuiltIn), _iconCache);
vm.Navigate += vm_Navigate;
using (var view = new FindSymbolDialog(vm))
{
view.ShowDialog();
}
vm.Navigate -= vm_Navigate;
}
private void vm_Navigate(object sender, NavigateCodeEventArgs e)
{
if (e.QualifiedName.Component == null)
{
return;
}
try
{
e.QualifiedName.Component.CodeModule.CodePane.SetSelection(e.Selection);
}
catch (COMException)
{
}
}
For these bitwise-or operations to work I had to modify the DeclarationType
enum a …bit (pun not intended), by making it a [Flags]
enum:
namespace Rubberduck.Parsing.Symbols
{
[Flags]
public enum DeclarationType
{
Project = 1 << 0,
Module = 1 << 1,
Class = 1 << 2,
Control = 1 << 3,
UserForm = 1 << 4,
Document = 1 << 5,
ModuleOption = 1 << 6,
Member = 1 << 7,
Procedure = 1 << 8 | Member,
Function = 1 << 9 | Member,
Property = 1 << 10 | Member,
PropertyGet = 1 << 11 | Property | Function,
PropertyLet = 1 << 12 | Property | Procedure,
PropertySet = 1 << 13 | Property | Procedure,
Parameter = 1 << 14,
Variable = 1 << 15,
Constant = 1 << 16,
Enumeration = 1 << 17,
EnumerationMember = 1 << 18 | Constant,
Event = 1 << 19,
UserDefinedType = 1 << 20,
UserDefinedTypeMember = 1 << 21 | Variable,
LibraryFunction = 1 << 22 | Function,
LibraryProcedure = 1 << 23 | Procedure,
LineLabel = 1 << 24
}
}
Solution
It looks like you can do the filtering and sorting of _declarations
just once in the view-model constructor (parse results don’t change, and ExcludedTypes
doesn’t change):
_declarations = declarations
.Where(declaration => !ExcludedTypes.Contains(declaration.DeclarationType))
.OrderBy(declaration => declaration.IdentifierName.ToLowerInvariant())
.ToList();
While we’re at it, it should be more efficient to use the overload of OrderBy
that takes an IComparer<T>
, so we’re not making lots of throw-away strings:
_declarations = declarations
.Where(declaration => !ExcludedTypes.Contains(declaration.DeclarationType))
.OrderBy(declaration => declaration.IdentifierName, StringComparer.OrdinalIgnoreCase)
.ToList();
This also lets us simplify Search
:
private void Search(string value)
{
var declarations = _declarations;
if (!string.IsNullOrEmpty(value))
{
declarations = declarations
.Where(declaration => declaration.IdentifierName.IndexOf(value, StringComparison.OrdinalIgnoreCase) != -1);
}
var results = declarations
.Select(declaration => new SearchResult(declaration, _cache[declaration]));
MatchResults = new ObservableCollection<SearchResult>(results);
}
Here I’ve used IndexOf
instead of Contains
so we can pass it a StringComparer
, again saving on the creation of string objects.
(Consider using string.IsNullOrWhiteSpace
instead of string.IsNullOrEmpty
depending on the behaviour you want.)
We can save a call to Search
if _searchString
hasn’t changed. Not sure if it will help much in this case, but it’s something to consider.
set
{
if (_searchString == value)
{
return;
}
_searchString = value;
Search(value);
}