Part IV - PowerShell GUIs - how to handle events and create a Tabbed Interface
This post is part of the Learning GUI Toolmaking Series, here on FoxDeploy. Click the banner to return to the series jump page!
Where we left off
Previously in Part III of our GUI series, we left off with a review of some advanced GUI options, things like radio buttons, text replacement and things like that. In this section, weāll grow our tool from Part I, our lowly Ping tool, and expand it to become a fully-fledged remote PC management tool. Weāll do this to learn our way through some new GUI concepts we havenāt run into before. Iāll take you through making most of this tool, and leave you at a place where you should be able to take it the rest of the way.
What weāll cover
If you walk through this guide, I guarantee that weāll cover each of these items below. If there is something youād like to see covered in a future post, send me an e-mail! Each of the items covered in this post came from requests from my readers.
Seriously.
I am gathering input for post five right now, so let me know whatād youād like to do with PowerShell, and we can make it happen.
Making a Tabbed Interface
Weāre going to talk about the right way to use this element, to make it obvious to users (something called āDiscoverabilityā) that more tools and options exist, if theyād only click this tab and explore around a bit!
In tab one, weāll drop in the whole of our pinging tool from Part I of this walkthrough, while weāll hide extra functionality in tabs two, three and four. For extra niftiness, weāll have the others tabs be disabled (unclickable) until the user first verifies that a PC is online in tab one.
Handling UI Events
This is the part that has me the most excited, mostly because I just learned about how to do it, and it solves so many issues. Want to have your GUI run a chunk of code when the user edits a textBox. Or handle the event which is triggered if the user moves the mouse over a button? We can even wait till the user is finished entering text, and then run a short chunk of code to validate an entry. These are all real world scenarios for a User Interface Developer, and we can easily deal with them in PowerShell.
Letās get started.
Tabs, not just for playing āThe house of the rising sunā
Now, I said weāll be bringing back our pingerā¦and we kind of will. But weāre not ready to open up that code, just yet.
See, the thing is that while it IS possible to add tabs to an existing project or UI, itās much easier to start off with a new project, get the tabs, status bar, dock panels, etc setup the way you want them to look, and then copy and paste in other GUI elements from Visual Studio, or some XAML code.
Letās start the right way with a tabbed UI. Weāll be making a new project for this walkthrough, and at this point, it should feel familiar. Launch Visual Studio, click New, New Project, WPF.
Pick a good starting size for your form (Iāll do 345x450), then add a Tab control from the toolbox.
I like to draw a somewhat wide tab, like this.
[
Then, right-click the top of the tabā¦uh, holder, right next to your last tab and choose Layout-Fill All. I do this because itās actually surprisingly hard to drag your tab to fill the window, so this is the easy shortcut.
[
This will expand the tabs to fill the whole UI.
Now that we have our tabs taking up the whole window, we can add more by right-clicking the background and choosing āAdd Tab Itemā. We can also rename the tabs by clicking one and using their properties, or going down to the XAML and changing the name property there.
For our purposes, weāll name the Tabs āComputer Nameā, āSystem Infoā, āServicesā and āProcessesā. Now, for the process of adding content to tabs.
This is a bit tricky
When it comes to adding guts to our tabs, I donāt like to build the new guts for each tab within the tab window itself.
No, instead, I like to make a new window (right click the project in Solution Explorer, Choose Add, then New Window, and pick Window WPF) for each of my tabs. Then I can decorate them there, get them sized up, and then copy-paste the XAML or the UI elements into the Tab where theyāll reside when Iām done.
This way, I make a good ComputerConnection.XAML
window once, then I can copy it and paste it as many times as needed, and reuse it later on down the line. If the codeās already embedded into a tabcontrol, pulling it out to reuse later can be tricky.
Making a new window for each tab is much easier for development
Now with a new window created, I pick the tools I need, build it here, and then when itās finished, I copy and paste it into the tabbed UI. I found it quite difficult to do this by manually dragging and dropping tools from the toolbox into the tab itself. If youāve got a gamer mouse or much better dexterity, or are just plain cooler than me (you probably are, I write a blog about a scripting language, that doesnāt scream āTaking the cheerleader to promā to me) then go ahead and build your whole tool, bypassing the method I provide here.
This is what a blank window looks like. Time to put some clothes on this naked baby!
So Iāve got my new window which Iāve named and titled ComputerName_Tab. Iām going to drop in an image, a textblock, a button, and a textbox, and change the background color to something dark and moody looking.
Important tip
This is a time to make sure weāre using good naming conventions for our UI elements. When we paste our XAML over to PowerShell snippet, weāll have a lot of $WPFButtons to keep track of, if weāre not careful. From now on, when we add elements, lets take the time to give them a logical name, like āVerifyOnline_Buttonā and āComputerName_textboxā, etc.
[
Hereās my XAML if you want to be lazy. Just copy and paste this after the window declaration in Visual Studio.
<Grid Background="#FF0B4A80">
<TextBlock TextWrapping="WrapWithOverflow" VerticalAlignment="Top" Height="89" Width="314" Background="#00000000" Foreground="#FFFFF7F7" Margin="22,21,101,0" >
This is the FoxDeploy official Awesome Tool. To begin using this tool, first verify that a system if online, and then progress to any of the above tabs.
</TextBlock>
<TextBox x:Name="ComputerName" TextWrapping="Wrap" HorizontalAlignment="Left" Height="32" Margin="21,142,0,0" Text="TextBox" VerticalAlignment="Top" Width="135" FontSize="14.667"/>
<Button x:Name="Verify_Button" Content="Verify Online" HorizontalAlignment="Left" Margin="174,142,0,0" VerticalAlignment="Top" Width="93" Height="32"/>
<Image x:Name="image1" Stretch="UniformToFill" HorizontalAlignment="Left" Height="98" Margin="10,174,0,0" VerticalAlignment="Top" Width="245" Source="C:\Users\Stephen\Dropbox\Speaking\Demos\module 13\Foxdeploy_DEPLOY_large.png"/>
</Grid>
Now, that Iāve got this configured the way that I like it, Iām going to copy this whole body of code, and paste it into the master .xaml file, the one with the tabs we made earlier.
In case itās not clear, you want to paste your code in between the TabItem tags
When you click away after pasting, the window will update in Visual Studio and you should see your same UI compressed into the tab. You may need to resize some things to make them fit, but this is looking good so far!
Itās the same UI, now within the tabbed screen!
Now, letās flesh out the rest of the tabs. For System Info, Iām going to copy and paste the formatting we already did for Part II of this blog series, excerpted here, after renaming some elements and removing cruft. Donāt worry about looking up the post unless you want to, what Iāve got here will work just fine if youāre following along.
<Grid Background="#FF0B4A80">
<Button x:Name="Load_diskinfo_button" Content="get-DiskInfo" HorizontalAlignment="Left" Height="24" Margin="10,10,0,0" VerticalAlignment="Top" Width="77"/>
<ListView x:Name="disk_listView" HorizontalAlignment="Left" Height="115" Margin="10,40,0,0" VerticalAlignment="Top" Width="325" RenderTransformOrigin="0.498,0.169">
<ListView.View>
<GridView>
<GridViewColumn Header="Drive Letter" DisplayMemberBinding ="{Binding 'Drive Letter'}" Width="80"/>
<GridViewColumn Header="Drive Label" DisplayMemberBinding ="{Binding 'Drive Label'}" Width="80"/>
<GridViewColumn Header="Size(MB)" DisplayMemberBinding ="{Binding Size(MB)}" Width="80"/>
<GridViewColumn Header="FreeSpace%" DisplayMemberBinding ="{Binding FreeSpace%}" Width="80"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
Alright, pasting that in to our UI, we should see the following.
This is a great time to take this code and try and run it in PowerShell. If it worksā¦youāre in business, and the next step is to hook up some of the UI elements with some Add_ methods.
Copied and paste and it worked in PowerShell!
At this point, weāll move on to the coolest part, which is how to handle events.
Events and how to handle them
This was something that confused me for a LONG time., in fact itās been the dreaded question of my bootcamps as well. This is how I used to handle questions like these.
Student: āStephen, how do I do something when the textbox loses focus, or when the text changes, or when blahBlah happens?ā.
Me: ā¦
Me: ā¦
Me: * runs to bathroom and hides till day is over*
Hereās how it works.
Every GUI element in a form has a collection of Events that get triggered on certain conditions. They have names which are pretty easy to understand too, things like MouseEnter, MouseLeave(this event name has always sounded kind of disappointed to me), texthasChanged, etc.
When weāre loading this our form code into memory, the c# compiler is transforming our XAML into intermediate language code, another phrase for code on itās way to becoming machine executable code. When that happens, we get a whole lot of what developers call āsyntax sugarāāwhich sounds awfully diabeticābut means that the tools help us do work and give us shortcuts.
One of those shortcuts is that the compiler snorts through our GUI like a pig looking for truffles, finds all of the events associated with the objects on our form, and sets up handlers for each of the events, in the form of methods that begin with Add_. Ā We can use these to setup a handler, so that when an event is triggered, something cool happens.
There are a metric pant load of events, for every element. You can see them by piping one of your objects into Get-Member like so:
$WPFComputerName | Get-member Add\* -MemberType Method -force
All we have to do is add some code after the event we want to handle, and thatās it. Sugar baby, SUGAR!
So, letās say you want to run a chunk of code when the user moves over to another element in your form (which is called LosingFocus, which happens to me constantly during the dayā¦hey I wonder whatās on redditā¦). For example, letās echo out the value of a box when a user moves away from the box.
$WPFComputerNameBox.Add_LostFocus({Write-Host $WPFComputerName.Text})
Iāll stage this in the context of a little mini GUI, hereās the XAML, and then below, the PowerShell code.
<Grid Background="Azure">
<Button x:Name="button" Content="Click Me" HorizontalAlignment="Left" Height="32" Margin="62,186,0,0" VerticalAlignment="Top" Width="106"/>
<TextBox x:Name="textBox" HorizontalAlignment="Left" Height="30" Margin="62,92,0,0" TextWrapping="Wrap" Text="Type stuff here" VerticalAlignment="Top" Width="106"/>
</Grid>
</Window>
Method Code
$WPFbutton.add_MouseEnter({Write-host 'Yay, the mouse is over me!'})
$WPFbutton.Add_MouseLeave({Write-host 'he didnt click me...:('})
$WPFTextBox.Add_TextChanged({Write-host "the user typed something, something like this $($WPFtextBox.Text)"})
As I interact with my form, you can see the background updating in real time.
https://www.youtube.com/watch?v=5cepMPD7j-s
Giving the Form some Smarts
With that done, Iām now going to go back and set the tabs for tabs 2, 3 and 4 as disabled, so that the user will have to interact with tab 1 before proceeding. Do this by adding IsEnabled=āFalseā to each TabItem header, like this:
<TabItem Header="System Info" IsEnabled="False">
Weāre going to plug some logic in at the bottom of our XAML/WPF Snippet. When the user launches this form, we want the $WPFComputerName.Text to default to the userās computer name. When the user clicks the $WPFVerify button, weāre going to ping the system, if it replies, weāll activate the rest of the tabs.
When I said earlier that every object in our GUI has a slew of events, that includes the form (background) of our app itself. Ā Weāll use the Formās own Add_Loaded({}) event handler to tell our form to run a snip of code when it finishes loading, which is a useful way to set a default value for a box, for instance.
Below that, weāll give our Verify button some code to run when it throws a click event (when the user clicks the button), via the .Add_Click({}) event handler method. Ā The code will try to ping the computer in the ComputerName box; if it replies, it will then enable each of the tabs for the user to click them by stepping through each item in the TabControl, and setting the āIsEnabledā property of each to $True, to turn the lights on.
$Form.Add_Loaded(
{$WPFComputerName.Text = $env:COMPUTERNAME}
)
$WPFVerify.Add_Click(
{if (Test-Connection $WPFComputerName.Text -Count 1 -Quiet){
write-host "$($WPFComputerName.Text ) responded, unlocking" $WPFtabControl.Items[1..3] | % {
$_.IsEnabled = $true}
}
else{
write-host "$($WPFComputerName.Text ) did not respond, staying locked" $WPFtabControl.Items[1..3] | % {
$_.IsEnabled = $false}
}
})
Now when the form loads, the user has to interact with tab 1 before they can go on to the rest of the UI.
User canāt click these till they ping a system first
For our System / Disk Info panel, Iām liberally applying the principle of code reuse to recycle the code from Part II of this series. Ā There we covered how to instruct PowerShell to assign certain properties to certain columns in a GridView table, using Binding. Ā This code will grab the hard drive info, and display it in a table on tab II.
$WPFLoad_diskinfo_button.Add_Click({ Function Get-DiskInfo { param($computername =$env:COMPUTERNAME)
Get-WMIObject Win32_logicaldisk -ComputerName $computername | Select-Object @{Name='ComputerName';Ex={$computername}},\` @{Name=āDrive Letterā;Expression={$_.DeviceID}},\` @{Name=āDrive Labelā;Expression={$_.VolumeName}},\` @{Name=āSize(MB)ā;Expression={\[int\]($_.Size / 1MB)}},\` @{Name=āFreeSpace%ā;Expression={\[math\]::Round($_.FreeSpace / $_.Size,2)\*100}} }
Get-DiskInfo -computername $WPFComputerName.Text | % {$WPFdisk_listView.AddChild($_)}
})
With all of these changes in place, when the user provides a valid computer name, the other tabs will all unlock, then they can click the Get-DiskInfo button andā¦get some disk info.
Wrapping up
Your next steps to flushing out the rest of the UI is to figure out how to apply the same principles from Tab II to the tabs for Services and Processes. Ā Iāve got you started here with the GUI for the Services Tab, itās up to you to handle the GUI for the processes tab, and the logic for both. Ā If you pay close attention to one of the column headings in the Services Tab, youāll find you have to think outside of the box to come up with the value Iām asking for there.
Should you come up with something youāre particularly proud of, please sanitize your code (remove company info) and share it in the comments. Ā Iād recommend making a Gist or putting your code on GitHub or PasteBin, rather than dumping in 30KB of PowerShell and XAML below :) Ā Iām always surprised by the creativity my readers display, particularly when proving me wrong :p
Part V - Building Responsive Apps using Runspaces and adding Progress Bars
Complete XAML
<Window x:Class="Tab_Me_baby_one_more_time.TabMe"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Tab_Me_baby_one_more_time" mc:Ignorable="d" Title="TabMe" Height="258.4" Width="486.4">
<Grid>
<TabControl x:Name="tabControl">
<TabItem Header="ComputerName">
<Grid Background="#FF0B4A80">
<TextBlock TextWrapping="WrapWithOverflow" VerticalAlignment="Top" Height="89" Width="314" Background="#00000000" Foreground="#FFFFF7F7" Margin="10,10,150.4,0" > This is the FoxDeploy official Awesome Tool. To begin using this tool, first verify that a system if online, and then progress to any of the above tabs. </TextBlock>
<TextBox x:Name="ComputerName" TextWrapping="Wrap" HorizontalAlignment="Left" Height="32" Margin="20,67,0,0" Text="TextBox" VerticalAlignment="Top" Width="135" FontSize="14.667"/>
<Button x:Name="Verify" Content="Verify Online" HorizontalAlignment="Left" Margin="173,67,0,0" VerticalAlignment="Top" Width="93" Height="32"/>
<Image x:Name="image1" Stretch="UniformToFill" HorizontalAlignment="Left" Height="98" Margin="9,99,0,0" VerticalAlignment="Top" Width="245" Source="C:\\Users\\Stephen\\Dropbox\\Speaking\\Demos\\module 13\\Foxdeploy_DEPLOY_large.png"/>
</Grid>
</TabItem>
<TabItem Header="System Info" IsEnabled="False">
<Grid Background="#FF0B4A80">
<Button x:Name="Load_diskinfo_button" Content="get-DiskInfo" HorizontalAlignment="Left" Height="24" Margin="10,10,0,0" VerticalAlignment="Top" Width="77"/>
<ListView x:Name="disk_listView" HorizontalAlignment="Left" Height="115" Margin="10,40,0,0" VerticalAlignment="Top" Width="325" RenderTransformOrigin="0.498,0.169">
<ListView.View>
<GridView>
<GridViewColumn Header="Drive Letter" DisplayMemberBinding ="{Binding 'Drive Letter'}" Width="80"/>
<GridViewColumn Header="Drive Label" DisplayMemberBinding ="{Binding 'Drive Label'}" Width="80"/>
<GridViewColumn Header="Size(MB)" DisplayMemberBinding ="{Binding Size(MB)}" Width="80"/>
<GridViewColumn Header="FreeSpace%" DisplayMemberBinding ="{Binding FreeSpace%}" Width="80"/>
</GridView>
</ListView.View>
</ListView>
<Label x:Name="DiskLabel" Content="Disk info for system: " HorizontalAlignment="Left" Height="24" Margin="117,10,0,0" VerticalAlignment="Top" Width="197" Foreground="#FFFAFAFA"/>
</Grid>
</TabItem>
<TabItem Header="Services" IsEnabled="False">
<Grid Background="#FF0B4A80">
<Button x:Name="Load_services" Content="Load Services" HorizontalAlignment="Left" Height="24" Margin="10,10,0,0" VerticalAlignment="Top" Width="77"/>
<ListView x:Name="service_listView" HorizontalAlignment="Left" Height="115" Margin="10,40,0,0" VerticalAlignment="Top" Width="355">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" DisplayMemberBinding ="{Binding ServiceName}" Width="80"/>
<GridViewColumn Header="DisplayName" DisplayMemberBinding ="{Binding 'DisplayName'}" Width="100"/>
<GridViewColumn Header="Status" DisplayMemberBinding ="{Binding 'Status'}" Width="80"/>
<GridViewColumn Header="AutoStart" DisplayMemberBinding ="{Binding 'AutoStart'}" Width="80"/>
</GridView>
</ListView.View>
</ListView>
<Button x:Name="Stop_service" Content="Stop Service" HorizontalAlignment="Left" Height="24" Margin="129,10,0,0" VerticalAlignment="Top" Width="77"/>
<Button x:Name="Start_service" Content="Start Service" HorizontalAlignment="Left" Height="24" Margin="258,10,0,0" VerticalAlignment="Top" Width="77"/>
</Grid>
</TabItem>
<TabItem Header="Processes" IsEnabled="False">
<Grid Background="#FF0B4A80">
<TextBlock Name="processes_text" TextWrapping="WrapWithOverflow" VerticalAlignment="Top" Height="89" Width="314" Background="#00000000" Foreground="#FFFFF7F7" Margin="10,10,150.4,0" > Do something cool :) </TextBlock>
</Grid>
</TabItem>
</TabControl>
</Grid>
</Window>
My syntax highlighter was causing issues with the PowerShell code I posted. Instead, youāll find the completed script here on the Github page for this post.
For additional reading on Events, Iād recommend checking out Keith Hillās Blog Post here, and June Blenderās excellent post here. Both helped me to finally understand how it is that this works, and I truly appreciate it.
If you have any features youād like to see me include next time, drop a comment here, hit me up on Twitter, or send me an e-mail at myFirstName At Foxdeploy.com. (You do have to replace MyFirstName with Stephen, by the way!)
If you have a particular scenario and itās interesting enough, I might write a whole post on it. So if you want to recreate the ConfigMgr 2012 UI in PowerShell XAML, let me know :)
Who knows where weāll go from hereā¦
GUI Progress bars? Must have!