Click here to Skip to main content
15,868,088 members
Articles / Desktop Programming / WPF

A Document Outline Window for C# Files, in WPF

Rate me:
Please Sign up or sign in to vote.
4.94/5 (62 votes)
12 Oct 2010CPOL14 min read 108.4K   1.4K   93   41
A Document Outline Visual Studio tool window for C# files, coded in WPF

Introduction

I find a lot of value in having code files organized. This means adhering to a certain order of elements (member variables up top, followed by properties, constructors, etc.), and listing variables/methods alphabetically where appropriate. This helps to quickly browse the file and to see what it contains, especially if it's large or is a file I've never worked with before. As you might imagine, then, I'm a fan of regions. They lend themselves nicely to this organization, and allow for a collapsed, "floorplan"-type view of the file.

However, many developers don't feel the same way. A lot of people greatly dislike regions due to their ability to hide code, or find no value in such organization. While I feel such problems can be mitigated simply by sticking to some best practices (no nesting regions, for example), the fact is, many developers and teams don't want them. Additionally, it can take a lot of effort to maintain regions properly and consistently. Tools like ReSharper can help, but can still be time-consuming with large, multi-thousand-line files.

For these reasons, I started thinking that the IDE should just do this for me. Like the navigation bar, I should be able to see a list of all elements in my file and navigate to them, but organized into logical, collapsible sections. Such a tool would allow me to forgo regions in my code, not force them onto other developers, but still receive their benefits.

Background

This tool explores a number of concepts. First, it involves creating a Visual Studio add-in and tool window hosting a custom control, as well as hosting a WPF control inside a WinForms control. Second, it uses the Visual Studio automation model and code model to explore the code programmatically. Finally, the main control is written in WPF, so we learn some basic things there.

Using the code

To run this add-in, simply build the project and drop its DLL and add-in file (DocOutlineCSharp.AddIn in the root of the project) into your Visual Studio add-ins directory (%UserProfile%\My Documents\Visual Studio 20xx\AddIns). Just create this folder if it doesn't exist. Then, when you open Visual Studio, go to Tools -> Add-in Manager, check the add-in, enable it on Startup if you wish, and click OK. If the window doesn't automatically pop up, or if you close it and want to pull it back up later, you can find a command for the window under View -> Other Windows -> Document Outline (C#). This is tested as working in VS2010 and 2008. It probably won't work in 2005 due to it using WPF.

If you wish to load the solution and be able to see it in action simply by pressing F5, just modify the add-in file you place in the add-ins directory to point to the DLL in the solution's output folder, as opposed to the local folder as it's set currently.

Okay, now on to the code!

The heart of a Visual Studio add-in is its Connect class. This is created for you when you make a new add-in, but in an effort to better understand it and "make it my own", I went through and reformatted/renamed things to better reflect my own programming style. This may or may not help you understand what the Connect class does if you haven't used it before.

The Connect class

For our purposes, there are a few points of interest in the standard Connect class. We must hook into the WindowActivated and WindowClosing events of the IDE so we know when to start outlining code. We do this in the OnConnection method, and we'll unhook them in the OnDisconnection method. What these event handlers do will be described later.

C#
// get environment window events and hook into WindowActivated and WindowClosing
winEvents = (WindowEvents)app.Events.get_WindowEvents(null);
winEvents.WindowActivated += new 
  _dispWindowEvents_WindowActivatedEventHandler(winEvents_WindowActivated);
winEvents.WindowClosing += new 
  _dispWindowEvents_WindowClosingEventHandler(winEvents_WindowClosing);

Now we must create the tool window, where we specify the user control the window should host. Originally, this control was written in WPF. This caused two issues. First, because it inherited from WPF's UserControl which is not COM-visible, the normal ref object ControlObject that Windows2.CreateToolWindow2 populates with a reference to an instance of our user control always came back null, which required providing a reference to the instance of our user control from inside the control itself as a workaround. Second, it curiously made the caption text of our tool window disappear when docked with other windows. Making the base user control WinForms and hosting a WPF control inside of that using an ElementHost fixes both problems. Now the caption works correctly, and ref object ControlObject is populated correctly as well.

Finally, in OnConnection, provide the user control with the reference to the environment app, and perform our first code outline operation (detailed later).

Event handlers

In the WindowActivated event handler we hooked up earlier, we want to re-outline the code since we switched to a new window, but only if:

  1. we have a valid Outline Window control, and
  2. the window we just switched to is a valid document window and not a tool window, and
  3. the outline window is currently not tracking any document, or it is tracking a document, but the document it is tracking is not the document that was just switched to.
C#
void winEvents_WindowActivated(Window GotFocus, Window LostFocus)
{
    if (OutlineWindow != null && GotFocus.Document != null && 
       (OutlineWindow.CurrentDoc == null || 
       (OutlineWindow.CurrentDoc != null && 
        OutlineWindow.CurrentDoc.Name != GotFocus.Caption)))

        OutlineWindow.OutlineCode();
}

The above conditions ensure that we re-outline code when switching documents, and that we don't unnecessarily do it again if we've just switched to a tool window and back to the code file we were already working in.

In the WindowClosing event handler, we simply clear out the outline tree, and let it re-populate from the WindowActivated event handler of the next activated window.

C#
void winEvents_WindowClosing(Window Window)
{
    if (OutlineWindow != null)
        OutlineWindow.ClearElements();
}

That's it for the Connect class; now on to our custom control that will perform the code outlining.

Custom WPF User Control - DocOutline

The control that the Visual Studio tool window will host is a WinForms control called DocOutlineHost. Inside of that, we have an ElementHost hosting the WPF control that will do the work. It's written in WPF to take advantage of some extra UI flexibility, and also because I wanted to get more familiar with it. Let's start with its constructor, which is pretty basic:

C#
public DocOutline()
{
    InitializeComponent();
    Application.ResourceAssembly = Assembly.GetExecutingAssembly();

    refreshButton.Click += new RoutedEventHandler(refreshButton_Click);
    expandButton.Click += new RoutedEventHandler(expandButton_Click);
        collapseButton.Click += new RoutedEventHandler(collapseButton_Click);
}

This gets called when the hosting WinForms control is referenced in the CreateToolWindow2 method from the Connect class mentioned previously. We set the ResourceAssembly here to allow us to use short, relative resource paths later. It seems it won't default to its own assembly, possibly because we're inside a Visual Studio add-in. We also hook up the Refresh, Expand, and Collapse buttons that we have.

Our main entrance into this control after its creation is via the OutlineCode method, which gets called initially from the OnConnection method as well as every time we activate a new document window. This is the meat of the logic of the tool.

OutlineCode()

First, we get all code elements in the file provided to us through the Visual Studio code model.

C#
elements = DTE.ActiveDocument.ProjectItem.FileCodeModel.CodeElements;

For each element, we "expand" it, seeing what it has, adding items to our outline tree if necessary, and expanding further child items within it (details later).

C#
for (int i = 1; i <= elements.Count; i++)
    ExpandElement(elements.Item(i), null);

Once all the nodes have been added to the tree, we want to sort them. The "kind" nodes (our groups, such as Fields, Properties, Public Methods, etc.) we want to sort according to a prescribed order that we define. We do this using a custom comparer, which I won't get into the details of here. The element nodes inside of each group node, we want to just sort alphabetically. Because a node can be either a grouping (containing code elements), or a class (containing grouping elements), and classes can be nested infinitely, we must perform the sort recursively, doing it alphabetically or using our custom comparer depending on the parent node type.

C#
private void SortNodes(ItemCollection items, IComparer<TreeViewItem> comparer = null)
{
    List<TreeViewItem> itemList = new List<TreeViewItem>();

    foreach (TreeViewItem item in items)
        itemList.Add(item);

    items.Clear();

    if (comparer != null)
    {
        itemList.Sort(comparer);
    }
    else
    {
        try
        {
            itemList = itemList.OrderBy(i => 
               GetTreeViewItemNameBlock(i).Text.ToString()).ToList();
        }
        catch { }
    }

    foreach (TreeViewItem item in itemList)
        items.Add(item);

    foreach (TreeViewItem item in items)
    {
        if (item.Items != null && item.Items.Count > 0)
        {
            SortNodes(item.Items, item.Name == "GroupNode" ? null : new KindComparer());
        }
    }
}

Finally, we expand all nodes so the user can see the full outline, and set this document as our "current document" for tracking purposes.

C#
ExpandCollapseChildren(elementTree.Items, true);

CurrentDoc = DTE.ActiveDocument;

Now we will see how we examine each code element and decide what to do with it.

ExpandElement()

For each code element we encounter, we may or may not want to add it to our outline tree, and we definitely want to expand it further to see what else is below it. The latter part is pretty easy, and just requires a recursive call. CodeElement.Kind will tell us what kind of code element we're dealing with. If the code element is a namespace, we don't want to add it to our tree, so it's as simple as grabbing its members (classes, enums, etc.) and expanding each of them.

C#
if (element.Kind == vsCMElement.vsCMElementNamespace)
{
    CodeElements members = ((CodeNamespace)element).Members;

    for (int i = 1; i <= members.Count; i++)
        ExpandElement(members.Item(i), parent);
}

If it's a class, we do want to add it to our tree, and we also want to expand each of its members. We grab the class' name, add a TreeViewItem for it, and also add it to our list of encountered elements along with its position in the file for navigating later.

C#
CodeClass cls = (CodeClass)element;

string fullName = cls.FullName;
string name = cls.FullName.Split('.').Last();

TreeViewItem classItem = CreateTreeViewItem(fullName, name, string.Empty, 
             string.Empty, false, "Classes", new vsCMAccess());

items.Add(classItem);
treeElements.Add(new EncounteredCodeElement() { 
       FullName = fullName, Name = name, Location = element.StartPoint });

Details of the CreateTreeViewItem method and how we will navigate to these items later is coming up. We then expand each child element with similar code as before.

The other element types are "everything else": fields, properties, methods, and so on. We have a giant switch statement for the purpose of casting the element based on its kind, and then we do a number of things. We want to grab its name, its type, whether or not its static (IsShared == true), whether or not it's constant (in the case of fields), and any access modifiers it may have. Based on this, we create a "kind" string, which is the "kind" (group) we are going to put this element into. The code for the "variable" type is below (kind is a string representing the name of the group we want this in).

C#
CodeVariable var = (CodeVariable)element;
name = var.Name;
fullName = var.FullName;
fullType = var.Type.AsString;

if (var.IsShared)
    kind += "Static ";
if (var.IsConstant)
    kind += "Constants";
else
    kind += "Fields";

access = var.Access;

There is some extra logic to do for function elements. We'd like the text on the TreeViewItem to display the entire method signature, instead of just the name of the method, so we must build this string ourselves by iterating CodeFunction.Parameters and adding parentheses and commas as necessary. We also add the access modifier to the "kind" string via a Dictionary mapping. If the name of the method is the same as the class we're within, we set the kind to "Constructors". We set the kind to "Event Handlers" if the following is true: the method has a return type of void and has two parameters, the first of which is of type System.Object and the second of which is derived from System.EventArgs. The code for all this is pretty straightforward, and can be seen in the attached source.

Adding to the TreeView

At this point, after gathering all relevant information from our code element and building our "kind"/group string, if we have a regular code item (not a class or namespace, we can tell this by checking if the "kind" string is not empty), we want to add it to our TreeView. First, we must decide which set of nodes to add the new node to. If we provided a parent element to the ExpandElement method, we use that parents' children. Otherwise, we use the root of the tree.

C#
ItemCollection items = parent != null ? parent.Items : elementTree.Items;

Now we find the "kind" item to add our element to, and if we can't find it, we create it. In this process, we use a method, GetTreeViewItemNameBlock, to grab the TextBlock element containing the user-visible name of each node we pass. You might think using the Name property of the TreeViewItem itself would be an easier solution for this, but that property would not allow for things like . and <> in the name, which we need to uniquely identify a method signature, etc. Also, sometimes we need both the user-visible name and the fully qualified name (stored in the TextBlock ToolTip, shown in the CreateTreeViewItem method), for example to find the element when clicked on later, so this method would be useful regardless.

We want to add the element's type to our TreeViewItem in bold. If it's a simple type, we just un-qualify it for readability and we're done. If it's a "typed" type (with <>), we want to un-qualify each type within the angle brackets, which takes a little logic which you can find in the source.

Finally, we can create our TreeViewItem and add it to our group's children, and add the element to our list of encountered elements and positions.

The final things to cover are our method for creating TreeViewItems, as well as the code to navigate to items from the tree.

CreateTreeViewItem()

In this method, we provide a list of things such as qualified and unqualified name and type, the kind/group, and any access modifiers, and create a TreeViewItem. This is essentially simple WPF UI manipulation in code. First, we create a new TreeViewItem, and a StackPanel, setting its Orientation to Horizontal.

C#
TreeViewItem item = new TreeViewItem();
StackPanel stack = new StackPanel();
stack.Orientation = Orientation.Horizontal;
stack.Height = 16;

Next we create a grid for icons. We grab icon resources from our assembly based on the "kind"/group and any access modifiers using a simple Dictionary mapping for each, and use a BitmapImage with a Uri to set this as the as the Source of an Image. The nice thing is we can grab multiple images, for example, the blue cube for fields and the padlock for private, and add them both to the grid and they will properly overlay each other.

C#
Image kindImage = new Image();
kindImage.Name = "kindImage";
kindImage.Source = new BitmapImage(new Uri(@"/Resources/" + 
                   kindImageMapping[kind] + ".png", UriKind.Relative));
grid.Children.Add(kindImage);

Next we create TextBlocks for the name and type, italicizing the name if it's a class and bolding it if it's a group, and bolding the type. We also set the ToolTip of each to be the fully qualified name for reference for the user. Finally, we add the image grid and both TextBlocks to the StackPanel, and set the StackPanel to the TreeViewItem's Header. We hook up events for MouseDoubleClick and Selected, and return the TreeViewItem.

Final Step... Navigation

The final step is navigation from the outline window. As with the normal Visual Studio navigation bar, we want the user to be able to double click an item and be taken to it in the source. Unfortunately, it seems that the MouseDoubleClick event behaves unusually in that it fires separately on every element from the origin up the visual tree to the highest parent, and thus cannot be stopped by handling the event. So, no matter what item we double click, we always get navigated to the highest parent instead. This can be stopped by caching the selected TreeViewItem when it's chosen, and only performing the navigation if the selected item matches the source of the double click event. In the Selected event handler:

C#
void item_Selected(object sender, RoutedEventArgs e)
{
    selected = sender as TreeViewItem;
    e.Handled = true;
}

And in our MouseDoubleClick event handler:

C#
if (selected != e.Source)
    return;

Next, we get the TextBlock containing the name in the selected TreeViewItem. We use this to find the element in our list of encountered elements and positions.

C#
TextBlock nameBlock = GetTreeViewItemNameBlock((TreeViewItem)sender);
EncounteredCodeElement foundElement = treeElements.Find(el => 
   el.Name == nameBlock.Text && el.FullName == nameBlock.ToolTip.ToString());

Then it's as simple as moving the DTE.ActiveDocument.Selection to the specified point.

C#
EnvDTE.TextSelection selection = (EnvDTE.TextSelection)DTE.ActiveDocument.Selection;
selection.MoveToPoint(foundElement.Location);

There is one other small piece of logic. By default, the above code moves the cursor to the beginning of the line of the code element. To get it to the beginning of the name of the element, as with the built-in navigation bar and to trigger VS 2010's highlighting feature, we must move the cursor to the first found parenthesis or the end of the line, and then find the first instance of the unqualified name, without any parentheses, moving backwards from that point. It must be done in this way to ensure we don't put the cursor on a type of the same name or on a parameter by mistake.

C#
if (!selection.FindPattern("("))
    selection.EndOfLine();

string name = foundElement.FullName.Split('.').Last();

selection.FindPattern(name, (int)vsFindOptions.vsFindOptionsMatchCase | 
                            (int)vsFindOptions.vsFindOptionsBackwards);
selection.CharLeft();

And we're done!

docoutline.png

Points of Interest

All in all, it's a relatively straightforward tool, but it does contain a few quirks and a good bit of logic. Notable oddities are needing the WinForms host to avoid the null ControlObject when creating the tool window and the missing caption, and the unique behavior of MouseDoubleClick as compared to other regular bubble-type WPF events. Also, getting the Aero look and feel on XP required explicitly including the Presentation.Aero DLL as a merged resource dictionary. There was definitely a good bit of learning to do, but most of this was related to Visual Studio add-in quirks and WPF itself (resource Uris, TreeView, events, etc.). The Visual Studio code model itself is pretty simple.

I find this tool very useful for navigating and browsing code files, and hopefully you will too! This is my first submission to The Code Project, so I would appreciate any feedback. Thanks!

History

  • 7/27/10 - Initial submission
  • 7/28/10 - Added instructions for running the add-in/solution
  • 8/17/10 - Improved event handler detection, fixed navigating to overloaded methods, cleaned up code to unqualify types, and added properly unqualifying typed parameters (with <>)
  • 9/8/10
    • Fixed: Classes not properly nesting, navigating to elements of the same name in different classes (by storing and looking up the fully qualified type as well), <T> not displaying in method names, and tool window caption disappearing when docked with other windows (by hosting WPF control inside WinForms control).
    • Enhancements: Switched to Aero appearance regardless of OS, added toolbar with expand/collapse all buttons, added category for structs, and fixed Visual Studio 2008 support.
  • 10/12/10 - Add-in converted to a VSIX extension and submitted to the Visual Studio Gallery (see beginning of article for details)

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
United States United States
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionPossible performance improvement ? Pin
Jake8695424-Nov-10 20:53
Jake8695424-Nov-10 20:53 
AnswerRe: Possible performance improvement ? Pin
tcpc28-Nov-10 10:17
tcpc28-Nov-10 10:17 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.