Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

WaitSpin, ProgresPanel and threads

0.00/5 (No votes)
31 Dec 1899 1  
WPF Progress controls and comparing related threads methods

Introduction

With my project CBR on CodePlex, I had to work on the new Task Parallel Library to be compared with old classical threads. This leads me on infinite progress bar control and a non-blocking or intrusive progress UI.

To share my work, you will find below:

  • How to write a WaitSpin control and create several designs in Expression Designer...
  • Create a custom ItemsControl to be a sliding panel with multi-progress cancelable items...
  • Multi-Threading : Threads, Taks and Background worker...

Screenshots

Screenshot: "Demonstration app - Threads and sliding panel"

Screenshot: "Demonstration app - WaitSpin designs"

WaitSpin control

The Code

It is derivated from the Control class. It is very simple, define a few properties and methods. The requirement for the template is to have a PART_LoadingAnimation that define a storyboard to animate the control.

/// <summary>
/// Enumeration for representing state of an animation.
/// </summary>
public enum AnimationState
{
    /// <summary>
    /// The animation is playing.
    /// </summary>
    Playing,

    /// <summary>
    /// The animation is paused.
    /// </summary>
    Paused,

    /// <summary>
    /// The animation is stopped.
    /// </summary>
    Stopped
}

/// <summary>
/// A control that shows a loading animation.
/// </summary>
public class WaitSpin : Control
{
    #region --------------------CONSTRUCTORS--------------------

    static WaitSpin()
    {
        FrameworkElement.DefaultStyleKeyProperty.OverrideMetadata(typeof(WaitSpin),
            new FrameworkPropertyMetadata(typeof(WaitSpin)));
    }

    /// <summary>
    /// LoadingAnimation constructor.
    /// </summary>
    public WaitSpin()
    {
        this.DefaultStyleKey = typeof(WaitSpin);
    }

    #endregion

    #region --------------------DEPENDENCY PROPERTIES--------------------

    #region -------------------- fill--------------------
    /// <summary>
    /// fill property.
    /// </summary>
    public static readonly DependencyProperty ShapeFillProperty = 
        DependencyProperty.Register("ShapeFill", typeof(Brush), typeof(WaitSpin), null);

    /// <summary>
    /// Gets or sets the fill.
    /// </summary>
    [System.ComponentModel.Category("Loading Animation Properties"), System.ComponentModel.Description("The fill for the shapes.")]
    public Brush ShapeFill
    {
        get { return (Brush)GetValue(ShapeFillProperty); }
        set { SetValue(ShapeFillProperty, value); }
    }
    #endregion

    #region -------------------- stroke--------------------
    /// <summary>
    /// Ellipse stroke property.
    /// </summary>
    public static readonly DependencyProperty ShapeStrokeProperty = 
        DependencyProperty.Register("ShapeStroke", typeof(Brush), typeof(WaitSpin), null);

    /// <summary>
    /// Gets or sets the ellipse stroke.
    /// </summary>
    [System.ComponentModel.Category("Loading Animation Properties"), System.ComponentModel.Description("The stroke for the shapes.")]
    public Brush ShapeStroke
    {...
    #endregion

    #region --------------------Is playing--------------------
    /// <summary>
    /// Playing status
    /// </summary>
    public static readonly DependencyProperty IsPlayingProperty = DependencyProperty.Register("IsPlaying", typeof(bool), typeof(WaitSpin),
        new FrameworkPropertyMetadata(new PropertyChangedCallback(OnIsPlayingChanged)));

    /// <summary>
    /// OnIsPlayingChanged callback
    /// </summary>
    /// <param name="d"></param>
    /// <param name="e"></param>
    private static void OnIsPlayingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (System.ComponentModel.DesignerProperties.GetIsInDesignMode(d))
            return;

        WaitSpin element = d as WaitSpin;
        element.ChangePlayMode((bool)e.NewValue);
    }

    /// <summary>
    /// IsPlaying
    /// </summary>
    [System.ComponentModel.Category("Loading Animation Properties"), System.ComponentModel.Description("Incates wheter is playing or not.")]
    public bool IsPlaying
    {...
    #endregion

    #region --------------------Associated element--------------------

    /// <summary>
    /// Associated element to disable when loading
    /// </summary>
    public static readonly DependencyProperty AssociatedElementProperty = DependencyProperty.Register("AssociatedElement", typeof(UIElement), typeof(WaitSpin), null);

    /// <summary>
    /// Gets or sets the associated element to disable when loading
    /// </summary>
    [System.ComponentModel.Category("Loading Animation Properties"), System.ComponentModel.Description("Associated element that will be disabled when playing.")]
    public UIElement AssociatedElement
    {...

    #endregion

    #region --------------------AutoPlay--------------------

    /// <summary>
    /// Gets or sets a value indicating whether the animation should play on load.
    /// </summary>
    public static readonly DependencyProperty AutoPlayProperty = DependencyProperty.Register("AutoPlay", typeof(bool), typeof(WaitSpin),
        new FrameworkPropertyMetadata(new PropertyChangedCallback(OnAutoPlayChanged)));

    private static void OnAutoPlayChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (System.ComponentModel.DesignerProperties.GetIsInDesignMode(d))
            return;

        WaitSpin element = d as WaitSpin;
        element.ChangePlayMode((bool)e.NewValue);
    }

    /// <summary>
    /// Gets or sets a value indicating whether the animation should play on load.
    /// </summary>
    [System.ComponentModel.Category("Loading Animation Properties"), System.ComponentModel.Description("The animation should play on load.")]
    public bool AutoPlay
    {...

    #endregion

    #endregion

    #region --------------------PROPERTIES--------------------
    ...

    /// <summary>
    /// Gets the animation state,
    /// </summary>
    public AnimationState AnimationState
    {
        get { return this._animationState; }
    }

    #endregion

    /// <summary>
    /// Gets the parts out of the template.
    /// </summary>
    public override void OnApplyTemplate()
    {
        base.OnApplyTemplate();

        //retreive the animation part
        this._loadingAnimation = (Storyboard)this.GetTemplateChild("PART_LoadingAnimation");

        if (this.AutoPlay)
            Begin();
    }

    /// <summary>
    /// Begins the loading animation.
    /// </summary>
    internal void ChangePlayMode( bool playing )
    {
        if (this._loadingAnimation == null) return;

        if (playing)
        {
            if (this._animationState != AnimationState.Playing)
                Begin();
        }
        else
        {
            if (this._animationState != AnimationState.Stopped)
                Stop();
        }
    }

    /// <summary>
    /// Begins the loading animation.
    /// </summary>
    public void Begin()
    {
        if (this._loadingAnimation != null)
        {
            this._animationState = AnimationState.Playing;
            this._loadingAnimation.Begin();

            this.Visibility = System.Windows.Visibility.Visible;
            if (AssociatedElement != null)
                AssociatedElement.IsEnabled = false;
        }
    }

    /// <summary>
    /// Pauses the animation.
    /// </summary>
    public void Pause()
    {
        if (this._loadingAnimation != null)
        {
            this._animationState = AnimationState.Paused;
            this._loadingAnimation.Pause();
        }
    }

    /// <summary>
    /// Resumes the animation.
    /// </summary>
    public void Resume()
    {
        if (this._loadingAnimation != null)
        {
            this._animationState = AnimationState.Playing;
            this._loadingAnimation.Resume();
        }
    }

    /// <summary>
    /// Stops the animation.
    /// </summary>
    public void Stop()
    {
        if (this._loadingAnimation != null)
        {
            this._animationState = AnimationState.Stopped;
            this._loadingAnimation.Stop();

            this.Visibility = System.Windows.Visibility.Hidden;
            if (AssociatedElement != null)
                AssociatedElement.IsEnabled = true;
        }
    }
}

The basic provided XAML style define the Visibility, ShapeFill and ShapeStroke properties. Joined is a simple ellipse template and a storyboard to play with shape transparency.

Note that the template is surrounded by a Viewbox binded to the control dimensions - that's the easiest way i found to make it adjustable because Canvas control is a nightmare...Ellipse properties are template binded to WaitSpin control properties Stroke="{TemplateBinding ShapeStroke}" Fill="{TemplateBinding ShapeFill}"

<Style x:Key="{x:Type local:WaitSpin}" TargetType="{x:Type local:WaitSpin}">
    <Setter Property="Visibility" Value="Visible" />
    <Setter Property="ShapeFill" Value="White" />
    <Setter Property="ShapeStroke" Value="#00000000" />
    <Setter Property="Template">
    <Setter.Value>
        <ControlTemplate TargetType="{x:Type local:WaitSpin}">
        <Viewbox Width="{TemplateBinding Width}" Height="{TemplateBinding Height}">
            <Canvas x:Name="Document" Width="100" Height="100" Clip="F1 M 0,0L 100,0L 100,100L 0,100L 0,0">
            <Canvas.Resources>
                <Storyboard.....
            </Canvas.Resources>

            <Ellipse x:Name="ellipse8" Width="15" Height="15" Canvas.Left="12" Canvas.Top="12" Stretch="Fill" StrokeThickness="1" StrokeLineJoin="Round" Stroke="{TemplateBinding ShapeStroke}" Fill="{TemplateBinding ShapeFill}" Opacity="0.66" />
        .....
            </Canvas>
        </Viewbox> 
        </ControlTemplate>
    </Setter.Value>
</Setter>
</Style>

Create Designs

The easiest way to create design is to combine Expression Designer and Blend. My model is based on a 100x100 document. In the Designer, create the shapes you want and export them with the following options: "selected objects" + (3rd) "XAML SL4 / CWPF canvas" + "Always name" + "Place grouped objects in container" + "Paths" + "Convert to effets"

Then, copy the canvas content from the exported file into a WaitSpin style copy to replace the ellipses.

Take also care about the order and name of elements if you use the pre-defined opacity storyboard that exist in the basic style, then just rename the elements to "EllipseX" or change the TargetName of each KeyFrames.

<Storyboard x:Key="PART_LoadingAnimation" x:Name="PART_LoadingAnimation" RepeatBehavior="Forever">
    <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" Storyboard.TargetName="ellipse1" Storyboard.TargetProperty="(UIElement.Opacity)">
<SplineDoubleKeyFrame KeyTime="00:00:00" Value="1"/>
.... 

Or create a new storyboard in Blend like the rotating one defined in the "ie" style and printed below - Visual Studio don't like it in the designer, but it's working.

<Storyboard x:Key="PART_LoadingAnimation" x:Name="PART_LoadingAnimation" RepeatBehavior="Forever">
    <DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[2].(RotateTransform.Angle)" Storyboard.TargetName="Document">
        <EasingDoubleKeyFrame KeyTime="00:00:02" Value="360"/>
    </DoubleAnimationUsingKeyFrames>
</Storyboard>

Note : Expression suite is out of the scope...I am not mastering it at all !

Multi-progress panel

For an online programm, often calls are made to a distant web service that is not allways responding a quick manner. So you launch some asynchronous methods and wait for a response. The idea is to make a "panel" like the download one in Internet Explorer, small, non intrusive, cancelable and multi-process in this case. I wan it to be MVVM compliant too !

First, we need to choose the base control and define the ViewModel : ItemsControl seems to answer the requirement, then lets write the ViewModel for our process items :

public class ProcessItem : ViewModelBase
{
    //init data
    public bool UseTempo { get; set; }
    public DateTime StartTime { get; set; }
    public bool CanCancel { get; set; }
    public bool ShowProgress { get; set; }
    public bool ShowPercentage { get; set; }

    private string _Title;
    public string Title
    {
        get { return _Title; }
        set
        {
            if (_Title != value)
            {
                _Title = value;
                RaisePropertyChanged("Title");
            }
        }
    }

    private string _Message;
    public string Message
    {
        get { return _Message; }
        set
        {
            if (_Message != value)
            {
                _Message = value;
                RaisePropertyChanged("Message");
            }
        }
    }

    public bool WaitForCancel { get; set; }

    #region cancel command

    private ICommand cancelCommand;
    public ICommand CancelCommand
    {
        get
        {
            if (cancelCommand == null)
                cancelCommand = new DelegateCommand(CancelCommandExecute, CancelCommandCanExecute);
            return cancelCommand;
        }
    }

    virtual public bool CancelCommandCanExecute()
    {
        return CanCancel;
    }

    virtual public void CancelCommandExecute()
    {
        WaitForCancel = true;
    }

    #endregion
}

To write the control create a class that inherit from ItemsControl.

public class ProcessPanel : ItemsControl

Then we override the OnApplyTemplate to retreive the opening and closing animation that are defined in the template

public override void OnApplyTemplate()
{
    base.OnApplyTemplate();

    _CloseStoryboard = (Storyboard)this.GetTemplateChild("PART_CloseStoryboard");
    _CloseAnim = _CloseStoryboard.Children[0] as DoubleAnimation;

    _OpenStoryboard = (Storyboard)this.GetTemplateChild("PART_OpenStoryboard");
    _OpenAnim = _OpenStoryboard.Children[0] as DoubleAnimation;
}

After that we only subscribe to the items collection event to expand or reduce the panel that will be animated by our storyboard. Note that the StoryBoard object arround the animation are the only ways to modify the From and To properties. Otherwise you will get exceptions with frezzed objects because theu are playing.

protected override void OnItemsChanged(System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
    base.OnItemsChanged(e);

    if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
    {
        Console.WriteLine("play open : from " + _OpenAnim.From + "to" + _OpenAnim.To);
        _OpenStoryboard.Begin();

        _OpenAnim.From = _OpenAnim.To;
        _OpenAnim.To += 35;

        _CloseAnim.From = _OpenAnim.To;
        _CloseAnim.To = _OpenAnim.From;
    }...

The style for our control is like below : I define a DockPanel that contains the storyboards, then a simple grid that has a border for shaping the control and an ItemPresenter that contains my ItemsControl collection. ItemTemplate is replaced with simple DataTemplate that is binded to the ProcessItem ViewModel :

<Style x:Key="{x:Type local:ProcessPanel}" TargetType="{x:Type local:ProcessPanel}">
    <Setter Property="Visibility" Value="Visible" />
    <Setter Property="Template">
    <Setter.Value>
        <ControlTemplate TargetType="{x:Type local:ProcessPanel}">
        <DockPanel x:Name="PART_Container" VerticalAlignment="Bottom" Panel.ZIndex="1000">
        <DockPanel.Resources>
            <Storyboard x:Name="PART_CloseStoryboard" x:Key="PART_CloseStoryboard">
                <DoubleAnimation x:Name="CloseAnim"
                Storyboard.TargetName="PART_Container"
                Storyboard.TargetProperty="(Height)"
                From="41"
                To="0"
                Duration="00:00:00.4000000" />
            </Storyboard>

            <Storyboard x:Name="PART_OpenStoryboard" x:Key="PART_OpenStoryboard">
...
        </DockPanel.Resources>
        <Grid Margin="0">
            <Border x:Name="top" Grid.ColumnSpan="4" CornerRadius="7,7,0,0">
            <Border.Background>
                <LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
                ...
                </LinearGradientBrush>
            </Border.Background>
            </Border>
            <ItemsPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
        </Grid>
        </DockPanel>
        </ControlTemplate>
    </Setter.Value>
    </Setter>
    <Setter Property="ItemsControl.ItemTemplate">
        <Setter.Value>
            <DataTemplate>
            <Grid Margin="0" Height="35">
                <Grid.ColumnDefinitions>
...
        <TextBlock Grid.Column="0" VerticalAlignment="Center" Margin="5" Background="Transparent"
        Text="{Binding Title}" TextWrapping="WrapWithOverflow" TextTrimming="WordEllipsis" />
        <TextBlock Grid.Column="1" VerticalAlignment="Center" Margin="5" Background="Transparent"
        Text="{Binding Message}" TextWrapping="WrapWithOverflow" TextTrimming="WordEllipsis" />
        <controls:WaitSpin Grid.Column="2" AutoPlay="True" Margin="6"></controls:WaitSpin>
        <Button Grid.Column="3" Margin="6" Content="x" Command="{Binding CancelCommand}" />
            </Grid>
            </DataTemplate>
        </Setter.Value>
    </Setter>
</Style>

Thread, Task and BackgroundWorker

Explanations

On my C.B.R. project, I gave a try to the TPL to compare with my thread implementation...that was a catastroph...So I choose to go deeper and try to compare these methods. I choose to implement a "recursive disk/folder parsing" method to find images. This allow us to continue also with how to use the WaitSpin and ProgressPanel controls.

The algorithm is allways the same and basically like below - depending on the method : BtnClick => create a ProcessItem => add it to collection => start a "process thread" => when finished, remove ProcessItem

Thread way

Nothing special but note that I use the BeginInvoke on the application thread to add and remove the item. It is asynchron and can lead into errors.

private void btnThread_Click(object sender, RoutedEventArgs e)
{
    ProcessItem pi = new ProcessItem()
    {
        Title = "btnThread_Click",
        Message = "btnThread_Click",
        CanCancel = true,
        ShowProgress = true,
        ShowPercentage = false,
        Data = this.tbFolder.Text,
        StartTime = DateTime.Now,
        UseTempo = chkUseTempo.IsChecked.Value
    };

    Thread t = new Thread(new ParameterizedThreadStart(LaunchThread));
    t.IsBackground = true;
    t.Priority = ThreadPriority.Normal;
    t.Start(pi);
}

private void LaunchThread(object param)
{
    try
    {
        ProcessItem pi = param as ProcessItem;

        Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (ThreadStart)delegate
        {
            _list.Add(pi);
            listBox1.Items.Add(pi.StartTime);
        });

        pi.Message = "Processing folders";

        ProcessMethod(pi, pi.Data as string);

        Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (ThreadStart)delegate
        {
            _list.Remove(pi);
            listBox1.Items.Add(DateTime.Now - pi.StartTime);
        });
    }
...}

internal void ProcessMethod(object param, string folder)
{
    ProcessItem pi = param as ProcessItem;

...

    pi.Message = "Processing folder " + directory.Name;

    foreach (FileInfo file in directory.GetFiles("*.*"))
    {
        if (pi.WaitForCancel)
        {
            pi.Message = "canceled by the user";
        return;
        }

        pi.Message = "Processing file " + file.Name;

        if (file.Extension == ".jpg")
        {
            Application.Current.Dispatcher.Invoke(DispatcherPriority.Normal, (ThreadStart)delegate
            {
                listBox1.Items.Add(file.Name);
            });
        }

...
}

TPL - First try...

The idea was : wow ! make some parallel tasks and see how quick it is....but I forgot that creating a Task is CPU expensive and that my hard disk is the bottleneck in this case ! Imaging that even if the framework can create 20000 task in a second my hard disk is not capable of reading file so quick ! Below is the first try :

public void ParallelProcessMethod(object param, string folder)
{
    ProcessItem pi = param as ProcessItem;

    try
    {
    DirectoryInfo directory = new DirectoryInfo(folder);
    if (!directory.Exists)
        ...

    Parallel.ForEach<FileInfo>(directory.GetFiles("*.*"), fi =>
    {
...

    Parallel.ForEach<DirectoryInfo>(directory.GetDirectories("*", SearchOption.TopDirectoryOnly), dir =>
    {
...    
        ParallelProcessMethod(param, dir.FullName);
    
...

Advise : Never do that! Be sure that the task number will not grow too much and that the processing is long enough to compensate the task creation cost.

Update : This is certainly due to Visual Studio and the debug mode ! See cpu and time comparison...

TPL - Second try...

Remove the Parallel.ForEach and everything gets better ! Here is the handler. I put the code for adding and removing the ProcessItem here to take advantage of the ContinueWith method. The process code is the same.

private void btnTask_Click(object sender, RoutedEventArgs e)
{
    ProcessItem pi = new ProcessItem()
    {
        Title = "btnTask_Click",
        Message = "btnTask_Click",
        CanCancel = true,
        ShowProgress = true,
        ShowPercentage = false,
        Data = this.tbFolder.Text,
        StartTime = DateTime.Now,
        UseTempo = chkUseTempo.IsChecked.Value
    };

    Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (ThreadStart)delegate
    {
        _list.Add(pi);
        listBox1.Items.Add(pi.StartTime);
    });

    Task tk = Task.Factory.StartNew(() =>
    {
        try
        {
            pi.Message = "Processing folders";
    
        ParallelProcessMethod(pi, pi.Data as string);
        }
        catch (Exception err)
        {
        }
    }).ContinueWith(ant =>
    {
        _list.Remove(pi);
        listBox1.Items.Add(DateTime.Now - pi.StartTime);
        //updates UI no problem as we are using correct SynchronizationContext
    }, TaskScheduler.FromCurrentSynchronizationContext());
    }
...

Background Worder to finish

I implemented the BackgroundWorker in a quick and classic way like below :

private void btnWorker_Click(object sender, RoutedEventArgs e)
{
    BackgroundWorker _Worker = null;

    //init the background worker process
    _Worker = new BackgroundWorker();
    _Worker.WorkerReportsProgress = true;
    _Worker.WorkerSupportsCancellation = true;
    _Worker.DoWork += new DoWorkEventHandler(bw_DoBuildWork);
    _Worker.RunWorkerCompleted += new RunWorkerCompletedEventHandler(bw_RunWorkerCompleted);
    _Worker.ProgressChanged += new ProgressChangedEventHandler(bw_ProgressChanged);

    ProcessItem pi = new ProcessItem()
    {
        Title = "btnWorker_ClickbtnWorker_Click",
        Message = "btnWorker_Click",
        CanCancel = true,
        ShowProgress = true,
        ShowPercentage = false,
        Data = this.tbFolder.Text,
        StartTime = DateTime.Now,
        UseTempo = chkUseTempo.IsChecked.Value
    };

    // Start the asynchronous operation.
    _Worker.RunWorkerAsync(pi);
}
...

void bw_DoBuildWork(object sender, DoWorkEventArgs e)
{
    ProcessItem pi = e.Argument as ProcessItem;

    Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (ThreadStart)delegate
    {
        _list.Add(pi);
        listBox1.Items.Add(pi.StartTime);
    });

    BackProcessMethod(pi, pi.Data as string);

    e.Result = pi;
}

internal void BackProcessMethod(object param, string folder)
{
    ProcessItem pi = param as ProcessItem;
    ...
    //the same as others
}

void bw_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
    ...empty
}

void bw_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
    ProcessItem pi = e.Result as ProcessItem;

    // First, handle the case where an exception was thrown.
    if (e.Error != null)
    {
    }
    else if (e.Cancelled)
    {
    // Next, handle the case where the user canceled the operation.
    // Note that due to a race condition in the DoWork event handler, the Cancelled
    // flag may not have been set, even though CancelAsync was called.
    }

    Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (ThreadStart)delegate
    {
        _list.Remove(pi);
        listBox1.Items.Add(DateTime.Now - pi.StartTime);
    });
}

Timing and CPU

My system disk "C:" is containing 125 241 files, 23 087 folders for a total of 163 Go.

BackgroundWorker = 00:00:08s 1070. Cpu is 50% max and use the 4 core

Task with Paralell (first try) = 00:00:04s with very high Cpu at 90% but we win 3s

Task = 00:00:07s 3214. Cpu is 50% max, but use less on the 4 core

Thread = 00:00:07s 7414. Cpu is 60% max, activity is the same as tasks

To summerize...

This little try, in my opinion, show :

  1. BackgroundWorker method has got a bit more code, seems a bit obsolet with his progress handler and more rigid
  2. Execution speed is nearly the same with a small advantage for Task
  3. Thread are more classical way of writing regarding linq style of the Tasks
  4. Tasks certainly reveals avantages in a more complex scenario !
  5. Use release mode out of Visual Studio to compare !

By the way, it is more a fashion than anything else - this is to make TPL fan overreact :-)

Conclusion

This article is a very quick draft of what you can use for further developpement. I hope you will enjoy it as much as I do...next improvment is to reduce the panel after user interaction...but I have troubles...

History

  • v1.0
    • First version: everything there. I would like to add a auto-hide function if mouse leave the panel...
  • v2.0
    • Second version: Update with cpu and times. New advise about the first try, Tasks seems to be better in release mode.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here