Friday, October 28, 2022

Notes About PowerShell: Adding a TreeView to the GUI - Part 2

See Part 1 here.

If you just want to see the code, without reading how we get there, the code listings are at the bottom of this page.

In Part 1, we built a basic Windows form with a blank treeview. Now we're going to populate the treeview. Probably the three most popular "databases" that are looked at in PowerShell in a tree form are the file system, the Windows Registry, and Active Directory domains. I plan to look at each of these three sources via a treeview, but let's start with something simpler, a simple family tree.

As you remember from Part 1, most of our GUI-window construction takes place in an external file, "tinker_GUI.ps1", which is dot-sourced from our primary script file, "tinker.ps1". That primary script file currently looks like this:

# Offload GUI-building into a different file to decrease clutter here.
. "$PSScriptRoot\tinker_GUI.ps1"

# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The name in the box is `"$($NameBox.Text)`".")
} elseif ($Win.DialogResult -eq "Cancel") {
    Write-Host("The X was pressed. I`'m not going to tell you the name that is in the box.")
} # end of If

If you've followed along with Part 1, you should be able to run the above script, and see a window with an empty treeview, a box for entering your name, and an OK button.

Since a treeview looks at data that is in a tree-like form, we're probably less interested in the user's name and more interested in the leaf-node endpoint of interest in our tree, and the path it takes to get to that leaf-node. So let's edit the second, external script file, so that it replaces the "Name" box with a "Node" box. Let's also take out the pre-fill of that box, and the activation/selection/focus of that box.

Use your PowerShell ISE to open the "tinker_GUI.ps1" script file, and make the following modifications, deleting the strike-out text and adding in the bolded text. You'll find it easier to change "NameBox" to "Node" by using the PowerShell ISE's Edit / Replace in Script... feature to replace all instances of "NameBox" with "Node".

     ...
     
$Win.AcceptButton = $buttonOK                            # Click = "Close form; I accept it as it now is."

# A textbox.The node.
$NameBoxNode = New-Object "System.Windows.Forms.Textbox"
$NameBoxNode.Size = "175,25"
$NameBoxNode.Location = "290,40"
$NameBox.Text = "Babushka"                               # Pre-fill the box.
$Win.Controls.Add($NameBoxNode)
$Win.ActiveControl = $NameBox                            # Once form shows, select this box.
  # Put a label with the box.
$Label_NameBoxNode = New-Object 'System.Windows.Forms.Label'
$Label_NameBoxNode.Text = "Enter your nameSelected Node:"
$Label_NameBoxNode.Size = "150, 25"
$Label_NameBoxNode.Location = '290, 20'
$Win.Controls.Add($Label_NameBoxNode)

   ...

Try running your script, to make sure it all works as expected.

We're also interested in the path to that node, so let's add a "Path to Node" box (by adding the bolded text in the indicated place).

     ...
$Label_Node.Location = '290, 20'
$Win.Controls.Add($Label_Node)

# Path to the node.
$NodePath = New-Object "System.Windows.Forms.Textbox"
$NodePath.Size = "175,25"
$NodePath.Location = "290,120"
$Win.Controls.Add($NodePath)
  # Put a label with the box.
$Label_NodePath = New-Object 'System.Windows.Forms.Label'
$Label_NodePath.Text = "Path to Node:"
$Label_NodePath.Size = "150, 25"
$Label_NodePath.Location = '290, 100'
$Win.Controls.Add($Label_NodePath)

# The TreeView object.
$TreeView = New-Object System.Windows.Forms.TreeView

   ...

Now, for our family tree.

Imagine two brothers, John and William. John has three kids, Mary, Fred, and Alvin, and Fred has one, Delbert. William has four, Estelle, Angus, Eugene, and Marvin. Let's make a tree that views these relationships, and put that tree into the treeview object on our GUI form.

Ideally, since adding things to the treeview is conceptually different than actually building the window and the node and path and treeview box forms, we'd put the coding instructions to add things to the treeview somewhere else besides in this file. However, once the form is shown on-screen (with the $Win.ShowDialog(), no code below that line is executed until that window form is closed.

So, our options are to embed the code in the midst of all this other code, or to put the code to an external file and then import it as a dot-sourced file into a location just above the $Win.ShowDialog() line, or to put the code into a function and call that function just prior to the $Win.ShowDialog() line.

Just for sake of example, let's add a couple of nodes, the two patriarch brothers, with the code embedded in the tinker_GUI.ps1 file:

   ...
   
$Win.Controls.Add($Label_TreeView)                       # Add the label to the form.

$TreeView.Nodes.Add("John")
$TreeView.nodes.Add("William")

# Display the form
$Win.ShowDialog()

   ...

Run the script, and you should start seeing the treeview come together.

Now, instead of embedding this code in the tinker_GUI.ps1 file, let's move that code out of it, and into a new file we can name tinker_add_nodes.ps1. So tinker_GUI.ps1 becomes:

   ...
   
$Win.Controls.Add($Label_TreeView)                       # Add the label to the form.

$TreeView.Nodes.Add("John")
$TreeView.nodes.Add("William")

. "$PSScriptRoot\tinker_add_nodes.ps1"                   # Import file with code to populate tree.

# Display the form
$Win.ShowDialog()

   ...

and tinker_add_nodes.ps1 becomes:

$TreeView.Nodes.Add("John")
$TreeView.nodes.Add("William")

Running tinker.ps1 should still produce the output you expect.

Now let's add the kids and grandkid.

$TreeView.Nodes.Add("John")
$TreeView.Nodes[0].Nodes.Add("Mary")
$TreeView.Nodes[0].Nodes.Add("Fred")
$TreeView.Nodes[0].Nodes[1].Nodes.Add("Delbert")
$TreeView.Nodes[0].Nodes.Add("Alvin")
$TreeView.Nodes.Add("William")
$TreeView.Nodes[1].Nodes.Add("Estelle")
$TreeView.Nodes[1].Nodes.Add("Angus")
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")

As you can see, the .Nodes values are arrays, that attach behind each other like the railroad cars of a train. The first level array, $Win.Nodes has two elements: .Nodes[0] contains the name "John". So by adding "Mary" and "Fred" as new element nodes to $Win.Node[0], "Mary" becomes $Win.Nodes[0].Nodes[0] and shows up in the treeview as "John's" daughter, as does "Fred" as $Win.Nodes[0].Nodes[1]. Don't worry too much if this doesn't yet make sense to you; it should start making more sense the farther we go and the more you work with it.

To help visualize this, we can add this code:

  ...
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
  ...

You'll see that the tree has been expanded out to Delbert, without you having to expand anything using your mouse. If you press TAB a couple of times, until the focus lands on the treeview box, you'll see "Delbert" is highlighted.

We can even move the TAB focus to the treeview programatically:

  ...
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
$Win.ActiveControl = $TreeView
  ...

You may recall we used the "$Win.ActiveControl" setting earlier, in the definitions for the "Node" box, but then deleted that. If it had not been deleted, this second instance would simpy overwrite the effects of the first instance, because it comes later in the code.

But speaking of the "Node" box, this'd be a great time to fill it with the selected node:

...
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
$Win.ActiveControl = $TreeView
$Node.Text = $TreeView.SelectedNode.Text

You might think that since this is a property of $Node, it should go with all the other property settings in the section of code where we first defined $Node. That's great thinking. Unfortunately, since PowerShell is an interpreted language rather than a compiled language, PowerShell doesn't know about the $TreeView.SelectedNode.Text until after it reads this section of code, long after it has passed that section of code. So this assignment needs to go here instead of there.

This would also be a good time to report the selected node after the OK button is pressed. Remember, this code is in the main file, tinker.ps1.

  ...
# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The namenode in the box is `"$($NameBoxNode.Text)`".")
} elseif If ($Win.DialogResult -eq "Cancel") {
    Write-Host("The X was pressed. I`'m not going to tell you the namenode that is in the box.")
} # end of If
  

If you click around in the treeview, say, by clicking on "Mary", you'll see the focus follows your click. But nothing else happens.

In order to perform some action when we click in the treeview, we'll need more code. In the definition for the treeview, we'll add this line:

  ...
$TreeView.Size = "240,400"
$treeview.add_NodeMouseClick({Write-Host("Ouch")})
$Win.Controls.Add($TreeView)
  ...

All this does is print "Ouch" to the PowerShell console. (You could print it to a pop-up message box with a slightly more-complicated statement - [System.Windows.Forms.MessageBox]::Show("Ouch"). (The brackets are kind of a short-hand way of creating an object on-the-fly, without the whole "$MessageBox = New-Object..." variable declaration thingy, but since this is a run-time "declaration", the dot, ".", becomes "::" (among other differences).))

Each time you click anywhere in the treeview area, you'll get an "Ouch".

However, what if we want to do more than a single command on a mouse-click?

We could continue adding code in this embedded manner, like so:

$treeview.add_NodeMouseClick(
  {
    Write-Out("Ouch")})
    Write-Host("You just clicked away from $($TreeView.SelectedNode).")  # Need to fix: click on the same already-highlighted name, and this message will be inaccurate.
    Write-Host("Stop it! That hurts!")
  }
)

Or we could use a variable here that functions like a function by standing in for a whole section of code, or use an actual function call. To keep the various code pieces separate as much as we can, and keep one section of code uncluttered by another portion of code, let's put the variable/function in another file, say, tinker_node_selected.ps1.

$TreeViewMouseClickEvent = {
    Write-Host("Ouch")
    Write-Host("You just clicked away from $($TreeView.SelectedNode).")  # Need to fix: click on the same already-highlighted name, and this message will be inaccurate.
    Write-Host("Stop it! That hurts!")
}

And we'll have to make two changes to tinker_GUI.ps1:

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms

. "$PSScriptRoot\tinker_node_selected.ps1"
 ...
$treeview.add_NodeMouseClick({Write-Host("Ouch")})
$treeview.add_NodeMouseClick($TreeViewMouseClickEvent)

Or we can create a function, and call that function:

function TreeViewMouseClickEvent {
    Write-Host("Ouch")
    Write-Host("You just clicked away from $($TreeView.SelectedNode).")  # Need to fix: click on the same already-highlighted name, and this message will be inaccurate.
    Write-Host("Stop it! That hurts!")
} # end of TreeViewMouseClickEvent function
and
$treeview.add_NodeMouseClick({TreeViewMouseClickEvent})

Notice that with the embedded and function methods, curly braces are needed, but not with the variable method. Notice also that the function method is not preceded by the $ sign, whereas the variable method is. Lastly, note that the variable declaration includes an = sign, whereas the function declaration does not.

For a fuller look at these three methods, see here. We'll continue on using the function method.

If you watch the console as you click around in the treeview, you'll notice that the name that appears in the console is the name you leave, rather than the name on which you click. That's not the behavior we want. The reason this happens is because the PowerShell scripting engine runs the function/variable/embedded code before changing the treeview's selected node. We don't want to process the name we just left, but rather the name we just clicked on. So we'll have to change our function, to use arguments that the Add_NodeMouseClick feature automatically provides to us:

   ...
  
Write-Host("You just clicked away from $($TreeView.SelectedNode).")  # Need to fix: click on the same already-highlighted name, and this message will be inaccurate.
Write-Host("You just clicked on $($_.Node.Text).")

   ...

The $_ represents the un-named variable given to our function which holds the arguments from the Add_NodeMouseClick event.

Those console messages aren't very valuable to us, though. Let's instead use this function to change out the text of the two text boxes on our form.

function TreeViewMouseClickEvent {
    Write-Host("Ouch")
    Write-Host("You just clicked on $($_.Node.Text).")
    Write-Host("Stop it! That hurts!")
# Fill in the "Node" and Path: fields on the form, based on the node just selected.
    $Node.Text = $_.Node.Text
    $NodePath.Text = $_.Node.FullPath
} # end of TreeViewMouseClickEvent function

This almost works like we want, except you'll notice that if you click on a plus or minus in the treeview, the "Node:" and "Path:" fields change, even if we haven't actually selected a different node. Thinking about it, that makes sense; the Mouse-click event is raised when we click the mouse in the treeview area. We just need to use a different event. Easy. We'll also change the name of our function, to make it more accurate as to what is going on.

In tinker_node_selected.ps1:

     ...

function TreeViewMouseClickEvent {
function TreeNodeSelected {
   ...   
} # end of TreeViewMouseClickEvent function
} # end of TreeNodeSelected function

And in tinker_GUI.ps1:

   ...
  
$treeview.add_NodeMouseClick({TreeViewMouseClickEvent})
$treeview.add_AfterSelect({TreeNodeSelected})
  
  ...

Now we have a working form with a working treeview that allows us to read the selected node and its path. Let's change our output to include both of these bits of data. In tinker.ps1:

   ...
   
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The node in the box is `"$($Node.Text)`".")

If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The data retrieved are:")
    Write-Host("`t            Node: `"$($Node.Text)`".")
    Write-Host("`tPath to the Node: `"$($NodePath.Text)`".")
    
    ...

Just for clarity, here are the four files we are using, in their entirety:

tinker.ps1:

# Offload GUI-building into a different file to decrease clutter here.
. "$PSScriptRoot\tinker_GUI.ps1"

# The computer is now looping forever waiting for user input.
# Once the input is the OK button or the Window's X (or
# keyboard equivalent), the window closes and the code below runs.
If ($Win.DialogResult -eq "OK") {
    Write-Host("The OK button was pressed. The data retrieved are:")
    Write-Host("`t            Node: `"$($Node.Text)`".")
    Write-Host("`tPath to the Node: `"$($NodePath.Text)`".")
} elseif ($Win.DialogResult -eq "Cancel") {
    Write-Host("The X was pressed. I`'m not going to tell you the node that is in the box.")
} # end of If

tinker_GUI.ps1:

# Initialize the PowerShell GUI
Add-Type -AssemblyName System.Windows.Forms

. "$PSScriptRoot\tinker_node_selected.ps1"

# Create a new window form.
$Win = New-Object System.Windows.Forms.Form
$Win.ClientSize = '490,460'                              # Size of window frame.
$Win.text = "My Parent Window"                           # Title of the Win frame.
$Win.BackColor = "#ffffff"                               # Background color of the Win frame.

# Create an OK button.
$buttonOK = New-Object 'System.Windows.Forms.Button'     # Create the button.
$buttonOK.Text = "OK"                                    # Puts text on the button.
$buttonOK.Location = '350, 240'
$buttonOK.Size = '60, 25'
$buttonOK.Anchor = 'Bottom, Right'                       # Keep button in relative bottom-right of Win.
$buttonOK.DialogResult = "OK"                            # "None|OK|Cancel|Abort|Retry|Ignore|Yes|No".
$Win.Controls.Add($buttonOK)                             # Add the button to the form.
$Win.AcceptButton = $buttonOK                            # Click = "Close form; I accept it as it now is."

# The node.
$Node = New-Object "System.Windows.Forms.Textbox"
$Node.Size = "175,25"
$Node.Location = "290,40"
$Win.Controls.Add($Node)
  # Put a label with the box.
$Label_Node = New-Object 'System.Windows.Forms.Label'
$Label_Node.Text = "Selected Node:"
$Label_Node.Size = "150, 25"
$Label_Node.Location = '290, 20'
$Win.Controls.Add($Label_Node)

# Path to the node.
$NodePath = New-Object "System.Windows.Forms.Textbox"
$NodePath.Size = "175,25"
$NodePath.Location = "290,120"
$Win.Controls.Add($NodePath)
  # Put a label with the box.
$Label_NodePath = New-Object 'System.Windows.Forms.Label'
$Label_NodePath.Text = "Path to Node:"
$Label_NodePath.Size = "150, 25"
$Label_NodePath.Location = '290, 100'
$Win.Controls.Add($Label_NodePath)

# The TreeView object.
$TreeView = New-Object System.Windows.Forms.TreeView
$TreeView.Location = "10,40"
$TreeView.Size = "250,400"
$treeview.add_AfterSelect({TreeNodeSelected})
$Win.Controls.Add($TreeView)                             # Add the tree view to the main window form.
  # Put a label with the object.
$Label_TreeView = New-Object System.Windows.Forms.Label
$Label_TreeView.Location = "10,20"
$Label_TreeView.Size = "150,25"
$Label_TreeView.Text = "My Tree Object:"
$Win.Controls.Add($Label_TreeView)                       # Add the label to the form.

. "$PSScriptRoot\tinker_add_nodes.ps1"                   # Import file with code to populate tree.

# Display the form.
$Win.ShowDialog() 

tinker_add_nodes.ps1:

$TreeView.Nodes.Add("John")
$TreeView.Nodes[0].Nodes.Add("Mary")
$TreeView.Nodes[0].Nodes.Add("Fred")
$TreeView.Nodes[0].Nodes[1].Nodes.Add("Delbert")
$TreeView.Nodes[0].Nodes.Add("Alvin")
$TreeView.Nodes.Add("William")
$TreeView.Nodes[1].Nodes.Add("Estelle")
$TreeView.Nodes[1].Nodes.Add("Angus")
$TreeView.Nodes[1].Nodes.Add("Eugene")
$TreeView.Nodes[1].Nodes.Add("Marvin")
$TreeView.SelectedNode = $TreeView.Nodes[0].Nodes[1].Nodes[0]
$Win.ActiveControl = $TreeView
$Node.Text = $TreeView.SelectedNode.Text

tinker_node_selected.ps1:

function TreeNodeSelected {
# Fill in the "Node" and Path: fields on the form, based on the node just selected.
    $Node.Text = $_.Node.Text
    $NodePath.Text = $_.Node.FullPath
} # end of TreeViewMouseClickEvent function

Next up, in Part 3, we'll do this using the file system.

No comments: