Because some things just work better in WPF.
Let’s say you had a form that collected a bunch of user input, and then had a button that would fire off some work. We’ll assume that you wanted to prevent people from firing off the work again before they know the results of the first time, so you’ll disable the button while you’re working. Something like this (leaving out the boring stuff about collecting the user’s input and displaying the results):
Import-Module ShowUI
StackPanel {
TextBlock -Name Output
Button "Click Me" -Margin 3 -On_Click {
$this.IsEnabled = $False
# Update the user about what we're doing:
$Output.Text += "We are doing some hard work...`n"
# Simulate doing some hard work ...
$times = $times + 1
Start-Sleep -Seconds 3
# Let them know about all our hard work
$Output.Text += "Work completed $times time(s).`n"
$this.IsEnabled = $True
}
} -Show
It’s pretty simple. In fact, I think there’s only three things worth pointing out:
- There are variables for named controls.
- If you want to make sure a control has time to update it’s display while you’re executing your event handler, call UpdateLayout().
- You can disable and enable buttons.
Now, try doing the same thing with Windows Forms.
This was my first attempt:
[void][reflection.assembly]::LoadWithPartialName("System.Windows.Forms")
# Create the form
$form = new-object System.Windows.Forms.Form
$form.Size = "250,250"
## Create text
$output = new-object system.windows.forms.label
$output.Anchor = "Top,Left,Right"
$output.AutoSize = $True
## Create button
$button = new-object system.windows.forms.button
$button.Anchor = "Bottom,Right"
$button.Text = "Click Me"
$button.Location = "150,180"
$button.Add_Click({
$button.Enabled = $False
$Output.Text += "We are doing some hard work...`n"
## Simulate doing work
$times = $times + 1
Start-Sleep -Seconds 5
# Let them know about all our hard work
$Output.Text += "Work completed $times time(s).`n"
$button.Enabled = $True
})
$form.Controls.AddRange(@($output, $button))
## show the form
[void]$form.showdialog()
Now, I bet you noticed that took a bit more code than the WPF version in ShowUI, but the thing about it is that I also had to manually specify the positions of the controls (and I had to set the size of the form to make that work). And after all that, it still doesn’t actually work, and it has a few ugly behaviors:
First of all, disabling the control doesn’t work, because Windows posts all click events as messages to the button’s message queue, and it doesn’t know that it should ignore them, because it only processes one at a time — when it’s done running the Click handler the first time, it notices there’s another event … and since it’s enabled, it processes it. If you click it five times while it’s disabled, you’re going to get the work done five more times… eventually.
On top of that, although the label updates without being told to, it didn’t push the button down the screen or resize the form, so eventually it overlaps the button, passes through the bottom of the form and disappears.
The next thing I tried was to actually unhook the event handler before I disable the button … but that had no effect either, because (as I wrote earlier) the button doesn’t actually ignore the clicks while it’s doing the work — it just queues them up for processing later.
Of course, I could do the work in a background job so that the button would return immediately, but that doesn’t meet the requirements I have for not queuing up extra work before the first work is done, and in fact, then I’d have extra work to do to create a time to check and see if the remote job was finished or not.
[New] Thanks to SAPIENDavid, I’ve realized the simple fix for the disabling problem, we just have to call DoEvents to empty the event queue before we re-enable the button.
Here’s what did work:
Add-Type -Assembly System.Windows.Forms
# Create the form
$form = New-Object System.Windows.Forms.Form -Property @{
Size = "200,70"
AutoSize = $true
}
# Create a FlowLayout
$panel = New-Object System.Windows.Forms.FlowLayoutPanel -Property @{
Dock = "Fill"
FlowDirection = "TopDown"
AutoSize = $true
}
## Create text
$output = New-Object system.windows.forms.label -Property @{
Dock = "Fill"
Text = "Click the button when you're ready to work.`n"
AutoSize = $true
}
## Create button
$button = New-Object system.windows.forms.button -Property @{
Anchor = "Bottom,Right"
Text = "Click Me"
}
$lastButtonClick = get-date
$button.Add_Click({
$button.Enabled = $False
$Output.Text += "We are doing some hard work...`n"
## Simulate doing work
$times = $times + 1
Start-Sleep -Seconds 5
# Let them know about all our hard work
$Output.Text += "Work completed $times time(s).`n"
#Process the pending messages before enabling the button
[System.Windows.Forms.Application]::DoEvents()
$button.Enabled = $True
})
$panel.Controls.AddRange(@($output, $button))
$form.Controls.AddRange(@($panel))
## show the form
[void]$form.showdialog()
To fix the other problems, I added a FlowLayoutPanel (which is very different from the StackPanel in WPF, but still serves the same purpose), and made all the relevant bits autosize (it’s always surprising to me when I need to drop back to WinForms, how everything has to be told to autosize and fill empty space).
That’s enough to take care of the output problem, and make the two solutions roughly equivalent (there’s still some differences, as you can see if you run them).
For what it’s worth, this was a real world question from a user at our Virtual User Group this morning, and I just couldn’t help sharing how much easier user interfaces are to write in ShowUI. Clearly a designer like PrimalForms makes laying out the controls easier — but when it comes to the little things, you still have to figure out to make them work, and actually implement your event handlers correctly.
Just for the record, here’s what it would take to implement the WPF solution without ShowUI:
Add-Type -Assembly PresentationFramework
$window = New-Object System.Windows.Window -Property @{
SizeToContent = 'WidthAndHeight'
Content = New-Object System.Windows.Controls.StackPanel
}
$output = New-Object System.Windows.Controls.TextBlock
$button = New-Object System.Windows.Controls.Button -Property @{
Content = "Click Me"
Margin = 3
}
$button.Add_Click({
$this.IsEnabled = $False
# Update the user about what we're doing:
$Output.Text += "We are doing some hard work...`n"
$Window.UpdateLayout()
# Simulate doing some hard work ...
$times = $times + 1
Start-Sleep -Seconds 3
# Let them know about all our hard work
$Output.Text += "Work completed $times time(s).`n"
$this.IsEnabled = $True
})
$window.Content.Children.Add($output)
$window.Content.Children.Add($button)
$window.ShowDialog()
[...] Disabling Events in ShowUI (Joel Bennett) [...]
Certainly not what I intuitively expected. I tried this in Visual Studio, and the WPF version behaved the same way as the Winforms. Haven’t tried from powershell.
I wonder if your example doesn’t work that well because you are using sleep, which is putting the UI thread to sleep and not simulating background work.
Here is a VS c# example that uses a timer to simulate a background thread, perhaps more realistic, and some code that might not be perfect but roughly addresses the problem I think.
private int _cnt;
private static object _syncLock = new object();
private Timer _timer;
private void button1_Click(object sender, EventArgs e)
{
if (button1.Enabled)
{
bool go = false;
lock (_syncLock)
{
go = button1.Enabled;
button1.Enabled = false;
}
if (go)
{
_cnt++;
textBox1.Text += _cnt.ToString() + Environment.NewLine;
_timer = new Timer() { Interval = 3000 };
_timer.Tick += (s, args) =>
{
_timer.Stop();
button1.Enabled = true;
};
_timer.Start();
}
}
Well, you’re absolutely right of course — the issue is that since the app is single-threaded, and since in Windows Forms (unlike WPF) the events are all handled as Window Messages (which are queued) directly to the button, those events aren’t “seen” by the app until it returns from the event handler. When this came up on IRC (the virtual user group), I did suggest that the problem could be avoided by using a background job …
However, it’s not as simple as creating a timer. A timer isn’t a simulation of work, it doesn’t do anything. In order to get the same results while actually doing work, we would need a background job (to get the work into a different thread). In order to use a background job with UI, you would need a timer with a repeating event that checked on the background job. That would be, architecturally, a better solution.
But honestly, putting work into a background job just to disable the button is overkill. I mean, if you said you wanted to put the work into a background job to keep the UI responsive, that’s clearly the only way to do it. But in this case, we’re just trying to make the UI unresponsive.
I am not an expert in how the Windows message loop works, and how it relates to the implementation of WPF / Winforms. But because WPF/Winforms are single threaded, the behaviour you are seeing is what I would expect.
Let’s assume there are 3 clicks on the button, I see the callstack / flow of execution as follows :
1. 1st Click
a) Click handler
b) Set button disabled
c) Do some work / sleep
d) Set button enabled
2. 2nd Click
a) Button is now enabled again from 1d, so Click handled
b) Set button disabled
c) Do some work / sleep
d) Set button enabled
2. 3rd Click
a) Button is now enabled again from 2d, so Click handled
b) Set button disabled
c) Do some work / sleep
d) Set button enabled
All I can say is … try the code in PowerShell.exe -STA
I believe you’ll find that the WPF one actually doesn’t respond to clicks while the thread is “working” (honestly, the whole window locks up), but the WinForms example (without that weird hack) process the clicks later — when it comes back from the first event handler. You’re right that we could improve it a lot with threads, but that’s a future post
Hello Joel,
Actually you can solve this issue by adding one line to your original WinForms click event before you enable the button:
[System.Windows.Forms.Application]::DoEvents()
Updated Event Script:
$button.Add_Click({
$button.Enabled = $False
$Output.Text += “We are doing some hard work…`n”
## Simulate doing work
$times = $times + 1
Start-Sleep -Seconds 5
# Let them know about all our hard work
$Output.Text += “Work completed $times time(s).`n”
#Process the pending messages before enabling the button
[System.Windows.Forms.Application]::DoEvents()
$button.Enabled = $True
})
Oh man. I can’t believe I forgot about that. See … this is why it’s important to ask someone who’s actually still using the technology, instead of the guy that hasn’t done a full WinForms app in 4 years.
Thanks for posting that, I’ll update the article.