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.
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:
- 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
- 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:
- 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.
- 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.
- New methods
- LoadFromFile() overrides the LoadFromFile() method of TTreeview as shown previously;
- SaveToFile() overrides the public SaveToFile() method of TTreeview as follows:
procedure TGtroSearchTreeview.SaveToFile(const FileName: string);
begin
if not SearchMode then // prevents accidental save when the component is in search mode
inherited;
end;
procedure TGtroSearchTreeview.SaveTreeToList;
var
i, j, Level: Integer;
Text: string;
begin
TreeviewStorage.Clear;
for i := 0 to Items.Count - 1 do
begin
Level:= Items[i].Level;
Text:= '';
for j:= 1 to Level do
Text:= Text + #9;
TreeviewStorage.Add(Text + Items[i].Text)
end;
end;
- SearchBox is a published property that is set when a TEdit component is assigned to it in the Object Inspector. If the property is not set, the component acts as a standard TTreeview.
- SearchMode is a read-only public property that is set when the search box is filled with some characters (not empty). In this mode, the content of the component is filtered in such a way that only the leaf-nodes that match the search string are in the treeview (with their parent branch-nodes). The content of the component is no longer in sync with the content of the file so that the treeview should not be saved. The overridden SaveToFile() methods prevents this from happening.
- OnSearchModeChanged is set or reset when the SearchBoxChange() event handler of the TEdit component assigned to the SearchBox property triggers, i.e., when a character is added or deleted from the search box.
- OnLeafNodeSelected occurs when the user selects a leaf-node. It reacts and allow the developer to initiate a process when a leaf-node is selected. The selection is taken care of by catching the WM_LBUTTONDOWN message of the treeview as follows:
procedure TGtroSearchTreeview.WMLButtonDown(var Msg: TWMLButtonDown);
var
Node: TTreeNode;
HitTests: THitTests;
begin
Node:= GetNodeAt(Msg.XPos, Msg.YPos); // reference of the node that was clicked
if Node <> nil then // prevents AV if click occurs outside the node area
begin
HitTests:= GetHitTestInfoAt(Msg.XPos, Msg.YPos); // where was the node clicked
if htOnLabel in HitTests then // node is selected
begin
if not Node.HasChildren then
begin
Node.Selected:= true;
HideSelection:= false;
if Assigned(FOnLeafNodeChecked) then
FOnLeafNodeChecked(Self, Node);
end;
end;
if htOnButton in HitTests then // Click on the [+] or [-] button
if Node.Expanded then Node.Collapse(False)
else Node.Expand(False);
end;
end;
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:
- Demo - contains the code of the demo program that I have used to develop and test the component.
- exe - contains the GtroSearchTreeviewDemo.exe file, an executable of the demo program; and
- data - contains the treeview data used by the demo.
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
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:
- TVToolPalette.txt contains a list of countries and some of their main cities. It is a two-level treeview that mimics the Tool Palette found in the Delphi 2009 IDE;
- TVMultiLevels.txt contains the same type of data but provinces of Canada and states of the United States were added as suplementary categories.
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.