A treeview with a search mode
July 11th 2011

The TGtroSearchTreeview component is a descendant of TCustomTreeview whose leaf-nodes can be searched for a match with a search string. It mimics the Tool palette treeview that users of Delphi 2009 and later versions use when they search tools or components but is more general since it is not limited to two levels as the Tool palette is.

Figure 1 - Delphi 2009 Tool Palette
Tool Palette
Figure 2 - Delphi 2009 Tool Palette in search mode
Tool Palette in search mode

It started on May 7th 2011 when I received an e-mail from Mohammad Esmaili from Tabriz, Iran. He asked me how to develop a treeview that would mimic the Tool Palette treeview found in Delphi 2009 and later versions.

Users of Delphi 2009 and later versions of Delphi often consult the Tool Palette, a treeview-like object that appears in the IDE both in design and in code modes. The main characteristic of this treeview is that it displays a search box where characters can be types as shown in Figure 1. This treeview is comprised on only two level: categories and tools.

When characters are typed in the search box, the treeview expands and displays only the tools (leaf-nodes) that match the search string leaving their categories (branch nodes) exanded. This treeview has a search mode!

The GtroSearchTreeview component that is presented here has been generalized to any treeview with any number of levels. In this context, only the "leaf-nodes" of the tree are searched for matches with the search text as shown in Figure 2.

The component

The TGtroSearchTreeview component is derived from TCustomTreeview. In order to function, it must be paired with a TEdit component that must be assigned to the "SearchBox" property of the component. In such case, each time a character is typed or deleted from the search box, the search string is used to find matches of the search string in the "leaf-nodes" of the component. When no assignment is made to the SearchBox property, the component behaves as a standard treeview component.

The OnChange event of the search box is the key to the search behaviour. When it is triggered (a character was added or deleted), all the nodes of the treeview are scanned one by one and a search for a match performed on each leaf-node, scoring "true" when such match exists, and "false" otherwise. In addition, when a "branch" node contains a child that matched the search string, itself and all its parents nodes are displayed.

Approach

I investigated two potential approaches to the solution of the problem:

  1. redraw the treeview and hide all the nodes that scored "false" - using the OnCustomDrawItem event of the treeview to draw each node manually avoiding texting out anything on the items that scored "false". I succeeded redrawing the treeview on each occurence of the OnChange event but I had to tweak the value of the result of Node.DisplayRect(false) which draws the treeview correctly but causes a lot of instability in the result; or
  2. reload the treeview and hide all the nodes that scored "false"- take the data from the .txt file where the treeview data is stored and load it in an intermediate TStringList memory storage. When the OnChange event is triggered, the treeview is reloaded from this memory storage and the nodes that scored "false" are filtered out. This is the method that I have used.

Major bug in Delphi 2009

Although I used the treeview component quite often when using Delphi 6 and Delphi 7, I had never used it with Delphi 2009. To my surprise, when experimenting with it, I found out that saving the tree view with its SaveToFile() method and reloading it with its LoadFromFile() method did not work. Only one node displayed and this node was comprised of only one letter. This prompted a search on the Web for a solution and I found it on the Embarcadero Developer Network where Marcus Tettmar wrote that it looks like SaveToFile() is saving in Unicode format but LoadFromFile() is expecting ANSI. This having been said, he proposed a workaround that inspired me a solution for the problem at hand.

Overriding the LoadFromFile() method

With Delphi 2009, for the reason mentionned above, I had no choice: I had to override the LoadFromFile() method as follows:

procedure TGtroSearchTreeview.LoadFromFile(const FileName: string);
begin
  TreeviewStorage.LoadFromFile(FileName, TEncoding.Unicode);
  LoadTreeFromStorage('');
end;

This code is fairly simple. First, it loads the data from the .txt file into an intermediate TStringlist storage. As discussed by Marcus Tettmar, I selected a TStringList componant because its LoafFromFile() method has a second argument Encoding of class TEncoding that loads the file in the proper character format.

Immediately after, the LoadTreeFromStorage() method is called:

procedure TGtroSearchTreeview.LoadTreeFromStorage(SearchText: string);
var
  List: TStringList;
  ANode, NextNode: TTreeNode;
  ALevel, i, LParentRem: Integer;
  CurrStr: string;
  Keep, KeepParent, KeepAncestors: Boolean;
  LevelRem: Integer;

  function GetBufStart(Buffer: string; var Level: Integer): string;
  var
    Pos: Integer;
  begin
    Pos := 1;
    Level := 0;
    while (CharInSet(Buffer[Pos], [' ', #9])) do
    begin
      Inc(Pos);
      Inc(Level);
    end;
    Result := Copy(Buffer, Pos, Length(Buffer) - Pos + 1);
  end;

begin
  List:= TStringList.Create;
  Items.Clear;
  Items.BeginUpdate;
  SearchText:= Lowercase(SearchText); // insures a case insensitive search
  FSearchMode:= Length(SearchText) <> 0; // true searcxh box not empty
  try
    try
      // assign the content of the treeview storage to List
      List.Assign(TreeviewStorage); 
      // the filter algorithm has been removed from this listing but it goes here
      ANode := nil;
      for i := 0 to List.Count - 1 do // for each line of the list
      begin // extract CurrStr from the list and provide the level
{ $IF DEFINED(CLR)}
        CurrStr := GetBufStart(List[i], ALevel);
{ $ELSE}
        CurrStr := GetBufStart(PChar(List[i]), ALevel);
{ $IFEND}
        if ANode = nil then
          ANode := Items.AddChild(nil, CurrStr)
        else if ANode.Level = ALevel then
          ANode := Items.AddChild(ANode.Parent, CurrStr)
        else if ANode.Level = (ALevel - 1) then
          ANode := Items.AddChild(ANode, CurrStr)
        else if ANode.Level > ALevel then
        begin
          NextNode := ANode.Parent;
          while NextNode.Level > ALevel do
            NextNode := NextNode.Parent;
          ANode := Items.AddChild(NextNode.Parent, CurrStr);
        end
        else raise ETreeViewError.CreateFmt('Invalid level (%d) for item "%s"', [ALevel, CurrStr]);
      end;
    finally
      Items.EndUpdate;
      if SearchMode then
        FullExpand;
      List.Free;
    end;
  except
    Invalidate;  // force repaint on exception
    raise;
  end;
end;

Except for the use of a permanent intermediate storage for the treeview data, this code is similar to the one presented by Marcus Tettmar (that used a temporary TScringlist storage).

Filtering the data

The search for matches is initiated when the user types or deletes a character from the Search Box. This triggers its internal OnChange event. LoadTreeFromStorage() is executed with the search string as its argument. A temporaty stringlist called List is filled with the data from the memory storage and the nodes that don't match are deleted before the treeview is built, generating a treeview displaying only the leaf-nodes that match and their parents. The code follows:

      if SearchMode then
      begin
        for i := List.Count - 1 downto 0 do // List is scanned from bottom to top
        begin
{ $IF DEFINED(CLR)}
          CurrStr := GetBufStart(List[i], ALevel);
{ $ELSE}
          CurrStr := GetBufStart(PChar(List[i]), ALevel);
{ $IFEND}
          CurrStr:= Lowercase(CurrStr); // insures a case insensitive search
          if ALevel >= LevelRem then // node is a leaf
          begin
            Keep:= pos(SearchText, CurrStr) > 0; // Search string found if true
            if Keep then // a match is found
            begin
              KeepParent:= true; // parent branch must be kept
              KeepAncestors:= true; // and Ancestors too
              LParentRem:= ALevel - 1; // remember the level
            end
            else
              List.Delete(i); // no match, delete the leaf
          end; // if ALevel = LevelRem

          if ALevel = LevelRem - 1 then // node is a branch
          begin
            KeepParent:= false; // reset KeepParent to false
            if KeepAncestors and (ALevel = LParentRem) then
            begin
              KeepParent:= true;
              LParentRem:= LParentRem - 1;
            end;
            if not KeepParent then
              List.Delete(i)
            else if ALevel = 0 then
            KeepAncestors:= False;
          end;
          LevelRem:= ALevel;
        end;
      end;

At this stage, the variable List contains a copy of the treeview data that we can work on. It is scanned from the last to the first item (note that each item is a future node) so that children (leaf-nodes) are read before their parents (branches). It is sure the the first item that is scanned is a leaf of a certain level.

When one node data is read, the level of the node is compared with that of the previous one stored in the LevelRem variable and the following logic prevails:

  1. ALevel >= LevelRem implies that the node is a leaf; If the search string is not matched, Keep is set to false and the node is deleted. If there is a match, Keep, KeepParent and KeepAncestors are set to true so that the node and all its parent nodes remain in the treeview.
  2. ALevel = LevelRem - 1 i.e., ALevel has jumped. This implies that the node is a parent node and therefore a branch. If the branch-node has no leaf-node (KeepParent = false), it is deleted. If it has (KeepParent = true), it is preserved. The logic of KeepAncestors is more complex: When KeepParent is set to true, KeepAncestors is also set to true and remains true until a node of level 0 is reached. It is then reset to false.

When the algorithm is through, LevelRem is assigned the value of ALevel and the cycle resumes until the first node is reached. At this point, the treeview can be built and displayed: only the the leaf-nodes that matched the search string and their parent nodes are displayed.

Once this is done, the treeview is built and its matching leaf-nodes displayed with all their ancestors.

Methods, properties and events

In this component, all the methods, properties and events of the TTreeview class have been preserved (it can be dangerous if nodes are added or deleted or if drag-and-drop is enabled). In order to provide proper behaviour to the component, the following methods, properties and events have been added.

How to use it

If you want to try the component, you have to download its code and store it somewhere in your project files. This file contains the file gtroSearchTreeview.pas which holds the code of the component and three folders:

If you just want to evaluate the demo program and the component, launch the executable located in the exe folder and evaluate it. If you are satisfied and want to use the component in the Delphi IDE, put the code of the component (GtroSearchTreeview.pas) in any design package, compile it and install it. The component will then be installed in the "gtro" pane of the component palette, ready to be used by the demo program or whichever program you want to vevelop with it.

The demo program

Demo with the results of the search for "c"
Figure 4
Demo with the treeview partly expanded

During the development and the tests of the component, I used the program that is contained in the Demo folder of the zip-file. I dropped the component (called "TV") and a TEdit component (called "SearchBox") on the form and assigned this TEdit component to the SearchBox property of the component.

As shown in Figure 3, I added various buttons and a radio group to allow loading the treeview data stored in one of two files:

When you launch the demo, the file TVToolPalette.txt treeview data file is loaded automatically in the component. You can easily load the other file by clicking on the radio button of the Select File radio group. Additionally, you can load any file containing treeview data using the "Load from file" button. In Figure 3, the TVMultiLevels.txt file is loaded and the user has selected "New York" as shown in the status bar.

Search mode

The Search mode of the component is set when the search box contains at least one character. When this mode is set, there is no longer any synchronization between the content of the component and that of the file. Saving the treeview while in search mode will corrupt the treeview file. That is why the "Save to file" button is disabled when in search mode.

In Figure 4, the treeview is in search mode since the letter "c" was typed in the search box. As a result, only those leaf-nodes containing the letter "c" are displayed: Quebec, Calgary, Chicago, San Francisco, Mexico and Acapulco. Note also the "Chicago" has been selected and is highlighted.

The highlighting of the selected leaf-node is performed using the OnCustomDrawItem() method of component (inherited from TTreeview).

Conclusion

The GtroSearchTreeview component is a treeview the leaf-nodes of which can be searched for matches with the search string. After the search, only the leaf-nodes that matched it and their parent branch-nodes are displayed the treeview.

The component should be used for display only and updating should be performed with a standard treeview. The main reason for this restriction is that the GtroSearchTreeview component uses an intermediate storage for the treeview data, a storage that must be updated when a node is added, deleted or dropped. In such instances, the developer should use the OnAddition, OnDeletion or OnEndDrag events of the treeview to handle the update.

Warning!
This code was developed for the pleasure of it. Anyone who decides to use it does so at its own risk and agrees not to hold the author responsible for its failure.


Questions or comments?
E-Mail
Last modified: February 12th 2015 12:35:59. []