Click here to Skip to main content
15,867,594 members
Articles / Desktop Programming / System

File System Controls in WPF (Version III)

Rate me:
Please Sign up or sign in to vote.
4.95/5 (20 votes)
31 Mar 2018CPOL20 min read 34K   3.6K   44   10
Lessons Learned on Software Design with WPF
Sources Binaries  
Image 1

Index

Introduction

This article is a continuation of a previous article on [1] developing file system controls in WPF. A lot of things have changed between 2014 and now and so I would like to update this project along with some very interesting learnings on:

  • the application of Software Design with WPF,
  • Task Library and process coordination,
  • and many other interesting highlights.

Image 2

Background

The development of my open source editor Edi is still on-going and part of the current re-design was a complete refactoring to ensure that each control is usable on its own or in tandem with other controls. The resulting controls should, furthermore, be themable with a non-specific WPF theming library (e.g.: MLib, MahApps.Metro, MUI).

An Overview

The project discussed in this article implements a set of controls that can, taken together, implement something similar to a Windows Explorer application:

Image 3

Each of the above controls is implemented in a seperated project and is, therefore, available with a separate NuGet package:

What's really cool about this set of controls is its software design. This design allows us to re-use these components in a similar, but different context without having to change the existing functionality. An example for this claim is the FolderBrowser component and its re-use:

Image 4

The above dialog re-uses the FolderBrowser and the Refresh/Bookmark DropDown controls from the previous example with the Explorer window.

The FolderBrowser control is available in a Dark, Light, or Generic theme. It is designed such that it can be used with different theming libraries. But this is also available within a different context of usage:

(see links and attached demo projects for more details).

This wide range of flexible re-use is possible, because the FolderBrowserLib is visually de-composed in each view item. This means the FolderBrowserLib offers more than one view to implement a folder picker control. We can then use our fantasy and add the flexibility of WPF to place these items into a container of our choice :-)

The software design for the controls shown above is of course MVVM compliant, which means that an integration into other MVVM compliant WPF projects is a piece of cake.

So, now that we know the controls, and we know we can use them in a flexible manner, its about time to lift the curtain and take a look at how things work technically behind the scenes. The next section documents this aspect in more detail.

A Technical Overview

The Generic Explorer

The dependency diagram below (also known as UML deployment diagram) for the Generic Explorer demo application offers a good entry point to explore the solution top-down. The Generic Explorer demo application is shown at the left side with the symbol labeled Explorer. This symbol shows the main entry point of the application which is in our case the executable WPF Application project with the same name.

The diagram below does not include references to projects or components, such as, log4net, DropDownButtonLib, InplaceEditBoxLib, UserNotification, or .Net standard libraries, such as, mscorlib. The reasoning for not showing these components is that it would just generate noise and not really be helpful for the purpose of this article.

Image 5

We can see in the above deployment diagram that the Explorer executable has (apart from the omitted references) only 2 references of interest, and it is the ExplorerLib dll project that holds all the references to the File System Controls (FSC), which are the subject of this article.

Now, looking inside the Explorer project we can see that it contains not a lot more than the MainWindow definition and even the MainWindow.xaml contains only a TreeListItemView user control that is realized in the ExplorerLib dll project (see MainWindow.xaml.cs for start-up and shut down code).

The ExplorerLib dll project contains the ApplicationViewModel that is initiated in the MainWindow.xaml.cs code and drives the MainWindow via bindings. The main window contains just the TreeListItemView which is bound to the

C#
public ITreeListControllerViewModel FolderTreeView { get; }

property in the ApplicationViewModel. The ITreeListControllerViewModel interface is implemented in the internal class TreeListControllerViewModel. So, its really the TreeListControllerViewModel inside the ApplicationViewModel that drives the UI of the Explorer application. The Explorer sample application contains about 6 controls that are all bound to the properties of the FolderTreeView property:

C#
// Binds to HistoryControl
IBrowseHistory<IPathModel> NaviHistory { get; }

// Binds to FolderBrowser
IBrowserViewModel TreeBrowser { get; }

// Binds to FolderControl
IFolderComboBoxViewModel FolderTextPath { get; }

// Binds to Refresh/Bookmark DropDownButton
IBookmarksViewModel RecentFolders { get; }

// Binds to Filter Control
IFilterComboBoxViewModel Filters { get; }

// Binds to FileListView
IFileListViewModel FolderItemsView { get; }

Each of the properties shown above traces into each of the FSC projects (FolderBrowser, FileListView etc.) shown in the previous dependency diagram. And looking into these projects, we can see there are also viewmodels that live behind these interfaces and control the life cycle of each control.

In summary, each FSC project contains a view definition and a matching viewmodel, these get instantiated and bound, somewhere in the sub-system of the MainWindow and ApplicationViewModel's code.

A few good questions to ask then are these:

  1. How does the Themed Explorer work with the FSC components when there is no theming here?
  2. How are the FSC controls synchronized when they have no references among themselves?

We answer the first question in the next section and get back to the synchronization question in a later section below.

The Themed Explorer

This section explains how the WPF theming in the Themed Explorer works and shows contrasts this solution with the Generic Explorer version, which has no references to a theming library. We consider the dependency diagram of the Themed Explorer sample application below:

 

Image 6

We can see here that the software design of the Themed Explorer sample application is very similar to the Generic Explorer discussed previously. The only addition, shown in the top left side are the MLib project libraries, the ServiceLocator and the Settings and SettingsModel projects.

The ServiceLocator and the Settings and SettingsModel projects contain standard sample code that I usually use when I want to quickly throw together a them-able application. The ServiceLocator is from the article by Josh Smith [3] and is basically here to replace more complicated containers like PRISM, WindsorCastle, or MVVM Light. The Settings projects contain application settings , such as, whether Light or Dark themes are by default preferred when there is no theme defined, yet.

But the more interesting stuff to look at in this article is the MLib, MWindowLib, and MWindowInterfaceLib stuff. These projects contain the main theming definitions in this solution. The MLib project contains the standard theming definitions for WPFs standard controls, such as, ListBox, TreeView, and so forth. A Dark or Light theme can be loaded by simply loading the MLib/Themes/DarkTheme.xaml or the MLib/Themes/LightTheme.xaml file in a given application.

The MLib theming library, not only supports themes like Dark and Light, but also aligns with Windows 10 guidelines by using Accent Colors defined in the operating system. This Accent Color is determined at the start-up of the Themed Explorer demo application:

C#
namespace Explorer.ViewModels
{
  public class ThemeViewModel : Base.ModelBase
  {  
    ...
  public static Color GetCurrentAccentColor(ISettingsManager settings)
  {
    Color AccentColor = default(Color);
  
    if (settings.Options.GetOptionValue<bool>("Appearance", "ApplyWindowsDefaultAccent"))
    {
      try
      {
        AccentColor = SystemParameters.WindowGlassColor;
      }
      catch
      {
      }
  
      // This may be black on Windows 7 and the experience is black & white then :-(
      if (AccentColor == default(Color) || AccentColor == Colors.Black || AccentColor.A == 0)
      {
        // default blue accent color
        AccentColor = Color.FromRgb(0x1b, 0xa1, 0xe2);
      }
    }
    else
      AccentColor = settings.Options.GetOptionValue<Color>("Appearance", "AccentColor");
  
    return AccentColor;
  }
  }
}

... and is then given to MLib's Theme Manager to initialize the theme correctly at application start-up:

C#
namespace Explorer
{
  public partial class App : Application
  {
    ...
  private void Application_Startup(object sender, StartupEventArgs e)
  {
    ...
      var appearance = GetService<IAppearanceManager>();   
    ...
    appearance.SetTheme(settings.Themes
                        , settings.Options.GetOptionValue<string>("Appearance", "ThemeDisplayName")
                        , ThemeViewModel.GetCurrentAccentColor(settings));
      ...
}

Extension Points

A major challenge, when developing controls is to make all of them look consistent. And the challenge is even harder, if you are looking at controls that have no references to the theming library. This is true for using one Accent Color among all controls, but could also apply to other common theme items, like glyphs, or custom colors etc. It is of course possible to completely re-template a given control in any WPF application, if the given control supports that. But BindToMLib project in the Themed Explorer sample application also shows a much simpler way for making themes look consistent.

The BindToMLib project contains resource key bindings that bind to a key in the MLib library and synchronizes a particular key in a target assembly (e.g.: HistoryControlLib):

XML
<ResourceDictionary
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    
  xmlns:options="http://schemas.microsoft.com/winfx/2006/xaml/presentation/options"
  xmlns:reskeys="clr-namespace:WatermarkControlsLib.Themes;assembly=WatermarkControlsLib"
  xmlns:MLib_reskeys="clr-namespace:MLib.Themes;assembly=MLib">
...
<SolidColorBrush x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type reskeys:ResourceKeys},
                 ResourceId=ControlAccentBrushKey}"
                 Color="{DynamicResource {x:Static MLib_reskeys:ResourceKeys.ControlAccentColorKey}}"
                 options:Freeze="true" />
...
</ResourceDictionary>

...and this is how WPF brushs (and other themeable items) can be synchronized among a theming library and all other components. This BindToMLib approach is technically cool because it does not require that we have to build additional interfaces (the resource keys themself are the interface) and the controls do not need explicit knowledge of a particular theming library(!) since the BindToMLib project acts like an extension point that can be thrown in whenever custom synchronization items are needed.

The AppearanceManager in MLib also supports an AccentColorChanged event, if this needs to be synchronized in code behind. But I found that I don't actually need it and it realy seems to be much more flexible to work with BindToMLib, because the usage of Component Resource Keys results in (wanted) compile time errors, should, at any time down the road, a resource key somehow be changed.

A careful reader of the Themeable Explorer dependency diagram might be wondering why there is a reference from BindToMLib to HistoryControlLib but no BindToMLib references to other FSC controls further below(?). Surely, these controls make use of an accent color and it surely is synchronized, but how?

The explanation is surprisingly simple. The MLib library defines the control templates and styles for common controls, such as, TreeViews and ListViews etc. These definitions are in sync with the current Accent Color (managed by the Appearance Manager) and are applied on a global application level. That means, the BindToMLib approach is only ever needed, if there is a special purpose custom control (like the History Control) that has theming requirements that are not covered by standard WPF controls themed in MLib.

Theming WPF with Modal Dialogs

An important point, in the way of theming and architecture, is to consider not only traditional code behind methods, like events and properties, but also consider what WPF has to offer. A traditional method may also be applicable as we can see in the modal browser dialog in the ExplorerTest and ExplorerTestMLib projects. Both projects have a little plus button "+" in the configuration section at right side in the middle of the window. This little plus button "+" opens a generic modal FolderBrowser dialog in the generic test application and (you guessed it) a themed modal FolderBrowser dialog in the themed demo application.

The FolderBrowser library project has no reference to MLib. How can it be used to open a modal dialog?

The traditional solution (inversion of control) to this problem is that the FolderBrowser library is designed to accept an instance of a modal dialog (be it themed or not) in order to build the complete view (dialog + content + viewmodel) at run-time. This can be verified when we look inside the ExplorerTestMLib project and review the ExplorerTestMLib.Demos.ViewModels.DemoViewModel.cs file:

C#
protected override Window CreateFolderBrowserDialog()
{
  return new ExplorerTestMLib.Demos.Views.FolderBrowserDialog();
}

This little method returns a modal themed dialog instance. That instance is used in the base class method at ExplorerTestLib.ViewModels.ApplicationViewModel:.

C#
private void AddRecentFolder_Executed(object p)
{
  string path;
  IListControllerViewModel vm;

  this.ResolveParameterList(p as List<object>, out path, out vm);

  if (vm == null)
    return;

  var browser = FolderBrowserFactory.CreateBrowserViewModel();

  path = (string.IsNullOrEmpty(path) == true ? PathFactory.SysDefault.Path : path);
  browser.InitialPath = path;

  var dlg = CreateFolderBrowserDialog();

  var dlgViewModel = FolderBrowserFactory.CreateDialogViewModel(
      browser, vm.RecentFolders.CloneBookmark());

  dlg.DataContext = dlgViewModel;

  bool? bResult = dlg.ShowDialog();

  if (dlgViewModel.DialogCloseResult == true || bResult == true)
  {
    vm.CloneBookmarks(dlgViewModel.BookmarkedLocations, vm.RecentFolders);
    vm.AddRecentFolder(dlgViewModel.TreeBrowser.SelectedFolder, true);
  }
}

So, in the generic case, this instanciates the generic dialog defined in ExplorerTestLib.ViewModels.ApplicationViewModel. And the themed dialog is instanciated per override in the inheriting ExplorerTestMLib.Demos.ViewModels.Demos class.

This section explored some basic techniques for developing WPF controls that support a generic application (no specific theming) or a non-specific theming library. We have seen that the development of controls without references to a specific theming libraries results in is own challenges that can be worked around using standard techniques like inversion of control (see modal dialog discussion) or binding of resource keys. And while there is a lot more we could talk about WPF, we will now switch gears, and talk about Task Library coordination among the FSC controls as an example.

Handling Browser Requests to Synchronize Views

The previously shown dependency diagrams indicate that all FSC controls could be applied in a flexible way, since there are no dependencies, for example, between the FolderBroser control and the FileListView control. But having no dependencies raises the question of how these controls can be synchronized to show consistent information about the file system?

The answer to that question lies in the implementation of the FileSystemModels.Browse.ICanNavigate and FileSystemModels.Browse.INavigateable interfaces. The ICanNavigate interface should be implemented by a control that can browse to a file system location and can also indicate that it now arrived at a different location (if the user requested this change via click in the control itself). The interface definition looks like this:

C#
public interface ICanNavigate
{
  event EventHandler<BrowsingEventArgs> BrowseEvent;

  bool IsBrowsing { get; }
}

The IsBrowsing property is set to true when a control is in the process of verifying a location and retrieves the data for display from the file system. This process can take a while, which is why it can be useful, if a view can bind to this property and show a busy indicator.

The BrowseEvent on the other hand, not only models the current browsing state, but indicates a change of state. It tells the listner(s):

  1. when the control starts to look at another location,
  2. what that location is,
  3. when the process is complete,
  4. and whether it was successful or not

These events are indicates with the BrowsingEventArgs class and the BrowseResult enumeration:

C#
public class BrowsingEventArgs : EventArgs
{
  public IPathModel Location { get; private set; }

  public bool IsBrowsing { get; private set; }

  public BrowseResult Result { get; private set; }
}

public enum BrowseResult
{
  Unknown = 0,
  Complete = 1,
  InComplete = 2
}

The ICanNavigate interface is an output interface, if we look at it from a control's point of view. The INavigateable interface on the other hand is an input interface, because it is used to tell the control: "Please show the data in location x.".

C#
public interface INavigateable : ICanNavigate
{
  FinalBrowseResult NavigateTo(BrowseRequest newPath);

  Task<FinalBrowseResult> NavigateToAsync(BrowseRequest newPath);

  void SetExternalBrowsingState(bool isBrowsing);
}

My experience is that controls that are navigatable are also able to change their location (via user input) on their own, which is why it seemed logical that INavigateable inherites from ICanNavigate. The first two methods shown above offer a way for requesting the control to change its location. The second method can be used to tell a control that another control is currently changing its location - so this control should ignore user requests right now.

The HistoryControl is an example for a control that implements ICanNavigate but not INavigateable, which is why splitting these interfaces was necessary.

Both interfaces ICanNavigate and INavigateable are used inside the

  1. TreeListControllerViewModel class and the
  2. ListControllerViewModel class

which are instantiated, in each demo application, to control the FSC controls. Lets have a look inside the ExplorerTestLib project of the ExplorerTest_FolderBrowserDemo.zip solution to understand these details by example. The constructor, of each mentioned controller class, registers the Control_BrowseEvent method with the ICanNavigate.BrowseEvent of each control. So, whenever a control changes location, and that request for change was not initialized by the controller, its firing an event that executes this method:

C#
void Control_BrowseEvent(
   object sender,
   FileSystemModels.Browse.BrowsingEventArgs e)

The Control_BrowseEvent method has two main threads, one for the case in which a control is changing location on its own and the change is not complete, yet. The controller has then to tell the other controls whats going on and wait for a final result event:

C#
if (TreeBrowser != sender)
  TreeBrowser.SetExternalBrowsingState(true);

if (FolderTextPath != sender)
    FolderTextPath.SetExternalBrowsingState(true);

if (FolderItemsView != sender)
    FolderItemsView.SetExternalBrowsingState(true);

The other main thread in the Control_BrowseEvent method is played out when a control indicates that it has successfully changed to a new location:

C#
var timeout = TimeSpan.FromSeconds(5);
var actualTask = new Task(() =>
{
  var request = new BrowseRequest(location, _CancelTokenSourc.Token);

  var t = Task.Factory.StartNew(() => NavigateToFolderAsync(request, sender),
                                      request.CancelTok,
                                      TaskCreationOptions.LongRunning,
                                      _OneTaskScheduler);

  if (t.Wait(timeout) == true)
      return;

  _CancelTokenSourc.Cancel();      // Task timed out so lets abort it
  return;                         // Signal timeout here...
});

actualTask.Start();
actualTask.Wait();

An event, in which a control indicates a successful change to a new location, is the moment in time when it is useful to ask all other controls to synchronize with that location, which is implemented in the NavigateToFolderAsync method. This method calls the NavigateTo/NavigateToAsync method of each control that needs synchronization. It is also used to initialize the controller along with all controls when the application starts up.

The above code sample shows how we can use a task to await the end of another class in a deterministic fashion. It supports a timeout/cancel option that is set to 5 seconds. There are two savety measurements that ensure that no tasks run twice and may even cause disk threshing in the process. There is the

  1. SemaphoreSlim in the NavigateToFolderAsync method and
  2. the OneTaskLimitedScheduler _OneTaskScheduler in each controller class.

The OneTaskLimitedScheduler _OneTaskScheduler, based on the blog posts by Stephen Toub [5], queues all tasks and executes them in an enforced sequential order, while the SemaphoreSlim blocks a thread until a previous thread has exit the critical section. These measurements taken together ensure that the UI display is consistent while remaining responsive.

The diagram below is a summary of the task coordination discussed in this section. It gives us a birds eye view that shows how the FolderBrowser control, on the left side, has navigated to a new location (e.g. the user has opened an expander and selected one of the new items below it). This new location is messaged to the controller in the middle, which in turn requests all other controls to also navigate to that new location. This main workflow changes the display 99% of the time. There are some minor workflows that require all controls to navigate to a "new location" (on app start-up or refresh), but these workflows use the same mechanism via the same methods discussed above.

Image 7

The discussion in this section shows a relatively simple way of coordinating a set of controls to show a consistent UI to the user. This discussion was written in the hope that it may be helpful to others who are challenged by a development of more than one control (e.g.: chart controls) that need to show consistent data in an application. There are more details, such as, whether all controls can navigate the same set of file system paths, that could be considered in this model. But I found the model outlined here still interesting, because it could be applicable in so many other cases.

About MLib

Most theming projects implement just one assembly and everything that you will ever need is just inside that DLL. The Mlib design on the other hand is different because I wanted to be more flexible in the application of styles and controls. This why it is available in at least two versions:

  1. MLib, MWindowLib, and MWindowInterfaceLib
  2. MLib, MWindowDialogLib, and MWindowInterfaceLib

Both versions of the theming library are available under the above Nuget links. The first version is the version discussed in this article, while the second version, which supports ContentDialogs, is already documented elsewhere [2].

Conclusions

This article highlights some important concepts that can be verified in an open source control project called File System Controls (FSC) in the attached code samples or on GitHub. This description on WPF controls and the usage of the Task Library are given in the hope that it can be useful to others since complete postings with working applications are not available to my knowledge. And although, the FSC use case is very interesting to me, I am certain that the patterns outlined are applicable to many other (not just WPF) applications.

Another interesting dialog, that I have not done, yet, would be to design a dark and light file picker control. This them-able file picker control could be used to load or save files and it would not be difficult to implement it based on FSC, because we would simply have to re-use the:

  • FileListView,
  • Refresh/Bookmark DropDownButton, and the
  • FolderControl

from the Explorer projects previously shown. So, there may always be something else to extend here :-)

The next section lists some of the specific learnings, from previous sections, in a more abstract way. These general statements are often difficult to verify unless one has experienced certain issues or technologies. Please use the previous sections and attached code samples if you need practical guides that backup the statements in the next section.

Lessons Learned

Layering Views

One of the key concepts in WPF is the Visual Tree, which allows us to layer controls on top of each other. This layering should always be composed such that controls, or part of it, can be re-used without change in a flexible manner (see FolderBrowser discussion in Overview section and Views in FolderBrowserLib).

Implementing (Modal) Dialogs

The implementation of a modal dialog should not result in a dependency on a certain theming library. Use, for example, Inversion of Control, to let the application take the reference and make it a run-time parameter for the construction of the actual dialog.

Referencing Theming Libraries in WPF

A themed WPF control should not reference a theming library directly.

Implementing this requirement makes the control much much more flexible and versatile as it otherwise would be. Just imagine: You have to use 2 controls and each comes with its own fixed theming library. This means you will have to include multiple (in-consistent) theming libraries in your project. Instead, we could use just one theming library and concentrate on development because, maintenance should be much easier.

This requirement is of course not required in a first implementation, but will be useful when you have to change from one theming library to another. It will then be easy if you consider standard methods like inversion of control or Extension Points instead of just using hard references. The two top-most reference diagrams below show 2 useful scenarios where the top-right is the preferred solution while the top-left is what most often is implemented. Try to avoid the reddish marked scenario, because its all but flexible and comes with later costs you might want to avoid.

Image 8

Referencing IOT Containers

An IOT container, such as, Caliburn.Micro, PRISM, or Windsor is frequently used in many .Net projects these days. I have seen controls, where, for example, the developer re-used the OnViewAttached event from Caliburn.Micro in his control. Doing so, is at first fine, but will limit the application of your control to a certain IOT. You will experience that limitation when you try to apply your control in a different project with a different type of IOT that may not offer the exact same interface and/or event. Others will of course experience the limitation right away.

I am not saying do not use an IOT but I am trying to say that you should not use it directly in the development of your WPF control. The above OnViewAttached event was, for example, used to initialize the viewmodel when it attaches itself to a control's view. This particular use case can also be implemented with a OnDataContextChanged event using pretty much the same program logic.

So, my recommendation is to think about standard .Net events and means as customization, such as, inheritance and override, in order to customize the behavior of your WPF control. This is particularly, valuable if it helps you to cut a reference to a certain WPF control, since the application of that WPF control is now much less restrictive than before.

Seperate Task Coordination from Control Implementation

The "art of doing good software design" for a control is not deterministic and always relies on a particular implementation, but it is often true that less is more. So, consider the different aspects before you try to hammer everything into one assembly. This is not only true for aspects of data modeling (view, viewmodel, model layers), but should also be watched for things like interactions and coordination of tasks. I really like the previously shown way of synchronizing views, and thats not because its the only true way this could ever be done, but because it is flexible enough to change around many things whithout having to change the controls themeselves.

History

References

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Germany Germany
The Windows Presentation Foundation (WPF) and C# are among my favorites and so I developed Edi

and a few other projects on GitHub. I am normally an algorithms and structure type but WPF has such interesting UI sides that I cannot help myself but get into it.

https://de.linkedin.com/in/dirkbahle

Comments and Discussions

 
PraiseVery Nice Explorer sample code Pin
thewizardcode16-Jul-23 9:27
thewizardcode16-Jul-23 9:27 
Questionreskey exactly where is this defined and what is it Pin
Member 1563588214-May-22 5:16
Member 1563588214-May-22 5:16 
QuestionVery interesting (and a "but") Pin
codejager14-Nov-20 8:45
codejager14-Nov-20 8:45 
QuestionFolderComboBox vs traditional BreadCrumb/Path control Pin
DavidInvenioDavid24-May-19 21:47
DavidInvenioDavid24-May-19 21:47 
PraiseAbsolutely amazing work! Pin
DavidInvenioDavid24-May-19 21:32
DavidInvenioDavid24-May-19 21:32 
GeneralMy vote of 5 Pin
tbayart2-Apr-18 22:36
professionaltbayart2-Apr-18 22:36 
GeneralRe: My vote of 5 Pin
Dirk Bahle3-Apr-18 2:41
Dirk Bahle3-Apr-18 2:41 
QuestionAbove "it's overengineered bloatcode" I say... Pin
Thornik28-Mar-18 10:59
Thornik28-Mar-18 10:59 
PraiseExcellent! Pin
spi27-Mar-18 4:30
professionalspi27-Mar-18 4:30 
GeneralRe: Excellent! Pin
Dirk Bahle27-Mar-18 10:56
Dirk Bahle27-Mar-18 10:56 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.