Click here to Skip to main content
14,039,595 members
Click here to Skip to main content
Add your own
alternative version

Stats

5.9K views
6 bookmarked
Posted 24 Jan 2019
Licenced CPOL

Easy Prototyping with Desktop Console like UI, Skia Drawings and Several REST like node.js Hosted Services

Rate this:
Please Sign up or sign in to vote.
Easy prototyping with desktop console like UI, Skia drawings and several REST like node.js hosted services

Motivation

When I develop some software solution, I have a rough idea of what the user interface will be. Sometimes, it can change drastically, and sometimes, it can remain almost the same as my initial design. When working with services, (non-UI) components or when making some complex computations or statistical calculations, it is often required to specify multiple parameters and to use them easily. Typing in a console is a natural way of achieving all of those. However, for displaying complex graphics and for good user interactions, the console is maybe not the first choice. More, when working with threads and using the console, it is not so easy to detect UI blocking or other common issues, just because every thread can easily update the console. So I have developed simple and customizable solution to address all of those small problems.

What You Will Be Able to Make if You Follow the Entire Article

The entire solution is created from scratch. It will require some typing and some work. If there is a place, where you give up and move to another reading, then this one is the best. This article will not explain the code line by line, you need to read the code itself and figure it out. Of course, if there are questions, I will try to answer them to my best knowledge.

The ready demo solution uses two services, one listening for http requests and the other listening for web socket messages, running on node.js server, using the same “business” logic. This logic is a simple JavaScript written method, that creates an object with two properties, enum and string, and sets them to random values. The client is a WPF application, using customized text block control looking like console, one text box for typing the so called “commands” and one control for WPF implementation of the Skia Graphics Library.

This is how http server (win 10 OS) console looks like:

This is how the Web Socket server (win 10 OS) console looks like:

and this is how the WPF client application looks like, after the help command has been executed in order to display implement commands, and 2 000 random points were generated by one of the servers and displayed in Skia UI element.

You can list available colors and change then to your taste. You will be able to see several color combinations through the article. This gif will show the sample in action.

Prerequisites to Follow This Article and Make the Described Solution

This solution was created around the end of 2018, so the current versions at this time of all products and corresponding NuGet or NPM packages were used. For desktop application, the following IDE was used:

  • Microsoft Visual Studio Community 2017, available as free download from MSDN

and the following NuGet packages have been installed, together with their dependencies:

  • MahApps.Metro
  • MahApps.Metro.IconPacks
  • Newtonsoft.Json
  • SkiaSharp
  • SkiaSharp.Views

For services, the following IDE was used:

  • Visual Studio Code, available as a free download from MSDN

as server application, hosting the services:

  • node.js, available as free download from nodejs.org

and the following npm packages:

  • express
  • body-parser
  • ws

For testing the http service, it might be useful to install an application like Postman, Fiddler or similar. Postman was used during this demo.
The demo solution can be completed by using other IDEs, so this is kind of a personal choice. After you decide which IDE to use, have it installed and having node.js properly installed, it is time to start.

Client - The Basics

Even though our client will be simple, it has to properly mimic a real application. So it needs to have content navigation and use other threads than the UI thread. MVVM pattern is going to be used, with a basic view model and implementation of ICommand interface. After implementing this skeleton, we are going to create custom text block, which will inherit the default one. We are going to add one DependencyProperty, CustomInlinesProperty, to this new control, so that we can bind to this property from view models and update it via binding. TextBlock has property called Inlines of type InlineCollection, but it is not a dependency one, so we cannot use it and we have to implement the custom one.

Creating Solution

We start Visual Studio and create a new project by clicking File →New - > Project… A “New Project” dialog appears, from which we select WPF app (.NET Framework) (even that WPF for .NET Core is available, we will use the old way for now), specify name and location and press OK. When making this article, I have created a folder called devdotnet on my D: drive, which is used for location, and the name of the solution and project is LocalBrowser. Build and run the solution to see that all is working fine.

Installing MahApps

We will install one NuGet package in order to have a nice UI for the client application. So we right click the project, LocalBrowser, and select Manage NuGet packages… from the dropdown. NuGet window is shown, we click on Browse tab and then type MahApps.Metro in search box and then perform a search. If we typed without typos, the first result will be the desired one and we have to select it and then click on Install button. A popup called Preview Changes will appear and we confirm by clicking OK.

After installation is complete, we will look for MahApps.Metro.IconPacks and we will install it as well, following the same approach. If we now click on Installed tabs, we should see something like that:

We again build and run the client. It should look exactly the same as the first time we build it. But not for long.

Using MahApps MetroWindow

We open solution explorer and expand LocalBrowser project. Then, right click on MainWind.xaml and select Rename… Then, we change the name to Shell. The XAML should be Shell.xaml and the code-behind file should be Shell.xaml.cs. Then, we open the code-behild file, Shell.xaml.cs. We right click on the file and select Remove and Sort Usings. Then, we right click on the MainWindow on line 8 and we select Rename and rename the class name to Shell as well. Finally, Shell class inherits Window class. We delete this Window class and type MetroWindow, then right click and select Quick Actions and Refactorings… and select using MahApps.Metro.Controls.

After that, the Shell initialize method should be underlined in red color, because we did not yet fixed the XAML file. Let’s save it and leave it like that for now. We open the XAML file, Shell.xaml. Let’s change it to this one. Keep in mind that we will further change this file in next steps.

Then, we go and open App.xaml. We change the XAML to this one. No more changes will be made to it.

We can open the app.xaml.cs and remove unnecessary usings there as well. Then, we build and run the client again. This time, we should see a much bigger metro window. We can change the width and height to values pleasant for us.

Create Folder Structure and Few Basic Classes

Now it is time to create proper folder structure of our solution and to make few basic classes, which we are going to use further. In Solution Explorer, right click your project name (LocalBrowser in my case) and from drop-down menu, select Add.. and then New Folder. Name the folder Custom. Following the same approach, create the following folders: Models, ViewModels, Views and Infrastructure. Your solution should appear like this now:

Right click on ViewModels folder and select Add -> Class. Name the class ViewModelBase. Add this code to it, so that it has the following content:

Ok, now it is time for next view model. Right click on ViewModels folder, select AddClass and this time, name the class ShellViewModel. We will go back to it shortly. Now, add two more view models, called ConsoleViewModel and ConsoleSimplifiedViewModel. Now, we will add two user controls in Views folder. As with ViewModels, we will change the generated code, so that they belong to main namespace. So, right click ViewsAdd and then select User Control… Name the user control ConsoleSimplifiedView. Add one more user control and name it AnotherView. Go to code-behind of ConsoleSimplifiedView and remove all unnecessary usings. Then, change the namespace to LocalBrowser only. The Initialize method will be underlined red. We will fix this shortly. Proceed the same way with other view.

Now, we go to XAML view and remove some of the markup there. We have to fix the x:Class, so that it correctly represent the changes we made in code-behind. We will change the console simplified view in the following paragraphs, but for the other view, we are ready – the markup should look like this:

It remains to create an ICommand implementation. So, we right click on Infrastructure and select AddClass and we name the class SimpleCommand. The final code for it is as follows:

At this point, we build and run the client again. It should run and look exactly as before. If it does not, read the section again and fix all possible issues, before going further.

Creating the Custom Text Block Implementation

We stated earlier that we will use a custom text block in order to add inline objects from view model via binding. Now, it is time to create our custom text block. It will inherit from framework TextBlock and it will just add one dependency property of type ObservableCollection<Inline> and it will raise an CollectionChanged event. So, this means it will not be thread-safe and we have to take care of this later, in the corresponding view model.

We select Custom folder from Solution explorer, right click and select AddClass. We name the class CustomInlinesTextBlock. Here is the code for it:

ConsoleViewModel for Custom Text Block

We need view model, which is going to work with custom text block and do some basic stuff. We want the following things:

  • To have methods which do some basic stuffs, no matter where custom control is placed, like clear all content displayed in text block, change the color of characters, change the background, display help. This method set should be accessible in other classes, which use the consoleviewmodel, so that their add other implementations as well.
  • Some way of keeping helpful user description for some methods and displaying it when needed.
  • Some way of processing set of strings, entered by users. First string will always be a command name and it is always required, and if there are following strings, they will be treated as parameters.
  • Ability to display “system messages”, from UI and from other threads, so when an error happens or something that might require user attention, we can display it easily.

This is the final code of ConsoleViewModel. Because it is too long and eventual screen-shot will be too high, we have to display it using HTML. Not the best way, but it works.

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reflection;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Shapes;

namespace LocalBrowser
{
    public class ConsoleViewModel : ViewModelBase
    {
        private ObservableCollection<Inline> inlines;
        private Brush foreground, background, paragraph;
        private const char whiteSpace = ' ';

        public ConsoleViewModel()
        {
            Inlines = new ObservableCollection<Inline>();
            ImplementedConsoleCommands = new Dictionary<string, Action>();
            CommandDescriptions = new Dictionary<string, string>();

            ImplementedConsoleCommands.Add("clear", ClearInlines);
            CommandDescriptions.Add("clear", "Clear all text displayed in console");
            ImplementedConsoleCommands.Add("cls", ClearInlines);
            CommandDescriptions.Add("cls", "Short for clear");

            ImplementedConsoleCommands.Add("color", ChangeForeground);
            CommandDescriptions.Add("color", "Change the color, used to display letters in console. 
                                     Example usage: color red");
            ImplementedConsoleCommands.Add("paragraph", ChangeParagraphBackground);
            CommandDescriptions.Add("paragraph", "Change the color of paragraph. 
                                     Example usage: paragraph green");
            ImplementedConsoleCommands.Add("background", ChangeBackground);
            CommandDescriptions.Add("background", "Change the background color of entire console. 
                                     Example usage: background blue");

            ImplementedConsoleCommands.Add("?", DisplayHelp);
            CommandDescriptions.Add("?", "Short for help");
            ImplementedConsoleCommands.Add("help", DisplayHelp);
            CommandDescriptions.Add("help", "Display information about all available commands");
            ImplementedConsoleCommands.Add("listcolors", DisplayAvailableColors);
            CommandDescriptions.Add("listcolors", "Displays all supported colors and their names");
            ImplementedConsoleCommands.Add("resetcolors", SetDefaultColors);
            CommandDescriptions.Add("resetcolors", "Set all color to default ones");

            SetDefaultColors();
        }

        public Dictionary<string, Action> ImplementedConsoleCommands { get; private set; }
        public Dictionary<string, string> CommandDescriptions { get; private set; }
        public string KeyInUse { get; private set; }
        public string[] ArgumentsInUse { get; private set; }

        public ObservableCollection<Inline> Inlines
        {
            get { return inlines; }
            set
            {
                inlines = value;
                OnPropertyChanged("Inlines");
            }
        }

        public Brush Foreground
        {
            get { return foreground; }
            set
            {
                foreground = value;
                OnPropertyChanged("Foreground");
            }
        }

        public Brush Background
        {
            get { return background; }
            set
            {
                background = value;
                OnPropertyChanged("Background");
            }
        }

        public void ProceedConsoleMessage(string userInput)
        {
            KeyInUse = string.Empty;
            ArgumentsInUse = null;
            if (userInput.ToLowerInvariant().IndexOf(whiteSpace) > -1)
            {
                string[] userInputWords = userInput.ToLowerInvariant().Split
                    (new char[] { whiteSpace }, StringSplitOptions.RemoveEmptyEntries);
                KeyInUse = userInputWords[0];
                ArgumentsInUse = userInputWords.Skip(1).ToArray();
            }
            else
            {
                KeyInUse = userInput.ToLowerInvariant();
            }
            if (ImplementedConsoleCommands.ContainsKey(KeyInUse))
            {
                ImplementedConsoleCommands[KeyInUse]();
            }
            else
            {
                MakeRun(userInput);
            }
        }

        public void ProceedSystemMessage(string message, bool warning = true)
        {
            App.Current.Dispatcher.InvokeAsync(() =>
            {
                Run run;
                if (warning)
                {
                    run = new Run() { Text = message, Foreground = Brushes.Red, 
                                      Background = Brushes.White };
                }
                else
                {
                    run = new Run() { Text = message };
                }
                Inlines.Add(run);
                Inlines.Add(new LineBreak());
            });
        }

        public void ProceedRunOnly(string message, Brush foreground, Brush background)
        {
            App.Current.Dispatcher.InvokeAsync(() =>
            {
                Run run = new Run() 
                          { Text = message, Foreground = foreground, Background = background };
                inlines.Add(run);
            });
        }

        private void ClearInlines()
        {
            Inlines.Clear();
        }

        private void ChangeForeground()
        {
            ChangeColor(ConsolePropertyToColor.Foreground);
        }

        private void ChangeParagraphBackground()
        {
            ChangeColor(ConsolePropertyToColor.Paragraph);
        }

        private void ChangeBackground()
        {
            ChangeColor(ConsolePropertyToColor.Background);
        }

        private void ChangeColor(ConsolePropertyToColor consolePropertyToColor)
        {
            string color = string.Empty; // for the exception handling
            try
            {
                if (ArgumentsInUse == null)
                {
                    MakeRun("In order to specify letters color you need to type word color 
                       followed by space then followed by name of the color, like color Red");
                    return;
                }
                else
                {
                    color = ArgumentsInUse[0].Trim().ToLowerInvariant();
                }

                switch (consolePropertyToColor)
                {
                    case ConsolePropertyToColor.Paragraph:
                        paragraph = (SolidColorBrush)new BrushConverter().ConvertFromString(color);
                        break;
                    case ConsolePropertyToColor.Background:
                        Background = (SolidColorBrush)new BrushConverter().ConvertFromString(color);
                        break;
                    case ConsolePropertyToColor.Foreground:
                    default:
                        Foreground = (SolidColorBrush)new BrushConverter().ConvertFromString(color);
                        break;
                }
            }
            catch (NotSupportedException)
            {
                ProceedSystemMessage(string.Format("This type of expression: 
                   {0} resulting in desired color: {1} is not suppored. Try with another one, 
                   like Red, Green, Blue, Black...)", KeyInUse, color));
            }
            catch (FormatException)
            {
                ProceedSystemMessage(string.Format("This type of expression: {0} 
                   resulting in desired color: {1} is not suppored. Try with another one, 
                   like Red, Green, Blue, Black...)", KeyInUse, color));
            }
            catch (Exception)
            {
                throw;
            }
        }

        private void DisplayHelp()
        {
            foreach (string s in CommandDescriptions.Keys)
            {
                MakeRun(string.Format("{0,-20}\t{1}", s, CommandDescriptions[s]));
            }
        }

        private void DisplayAvailableColors()
        {
            Type brushesType = typeof(Brushes);
            PropertyInfo[] brushesProperties = brushesType.GetProperties
                       (BindingFlags.Static | BindingFlags.Public);
            InlineUIContainer uc;
            Run run;
            Rectangle rectangle;
            BrushConverter bc = new BrushConverter();
            SolidColorBrush brush;
            foreach (PropertyInfo pi in brushesProperties)
            {
                brush = (SolidColorBrush)bc.ConvertFromString(pi.Name);
                rectangle = new Rectangle() { Width = 100, Height = 20, Fill = brush };
                uc = new InlineUIContainer(rectangle);
                run = new Run() { Text = string.Format("  {0}  ", pi.Name) };
                Inlines.Add(uc);
                Inlines.Add(run);
            }
            Inlines.Add(new LineBreak());
        }

        private void MakeRun(string message)
        {
            Run run;
            if (paragraph == null)
            {
                run = new Run() { Text = message };
            }
            else
            {
                run = new Run() { Text = message, Background = paragraph };
            }

            Inlines.Add(run);
            Inlines.Add(new LineBreak());
        }

        private void SetDefaultColors()
        {
            Foreground = Brushes.White;
            Background = new SolidColorBrush(Colors.Black);
            Background.Opacity = 0.9;
            paragraph = null;
        }

        private enum ConsolePropertyToColor
        {
            Foreground,
            Paragraph,
            Background
        }
    }
}

ConsoleSimplified and Shell – Draft

This is the place where we create a working draft for those two view models and add some markup to their corresponding views. Bear in mind that we will change both view models in part three of this article. Below, you can find the draft for ConsoleSimplifiedViewModel.

and this is the corresponding draft markup view:

and here is the markup for shell view. Please notice the spinning font awesome icon. It might seems deliberate, but it will show us whether our UI hangs or is always responsible. This view will remain unchanged, we will only change a little bit the corresponding view model in the third part of article.

and here is the ShellViewModel. Let's say it once more - this is draft and it will be slightly changed in part three of this article. Client Again

We are almost at the end of part one of the article. It remains to set the DataContext of shell to its view model. We can do this in XAML or in code-behind. Or in few other ways, outside the scope of this article. This time anyway we do it in code-behind, just like that. So, this is the code-behind of shell view and it will remain like that for the entire demo solution.

and this is how the solution explorer looks at the end of part one:

We build and run the application. We should see the spinning logo and be able to navigate from one user control(view) to another by clicking the icons. If you notice, that if you navigate to console view and start typing, but no characters are shown, then you notice correct. This is due to the fact that textbox is not automatically focused when control is loaded. You can try to fix this in XAML, but probably it won't work. The proper fix for this is not yet implemented, and it will be implemented when? - yes, in part three. We will fix this with small code-behind trick, which is really good explained in separate code project article by different author. So the desired behaviour is just as it is shown on the showcase gif at the beginning of this article. However, at this point, we can still do some stuff with our console. Let's run the application, navigate to console view and focus the text box. Then list all available colors by typing listcolors and press enter. You can choose some colors and change the font color or background color or paragraph color. This is how I changed the console for the last screen-shot of part one:

Node.js Hosted REST like Services

In this part of the article, we discuss creation of two services, hosted on node.js. Those services are quite like REST, they are stateless, client which will connect them cannot say whether they are connected directly or via intermediary, so they are layered system, there is no way to get stale data, so we have cacheability, etc. There are several features which should be implemented by a web service so that it can be REST, I did not check the entire list, so for that reason, we call it REST like.

We are going to implement two services, one handling http requests and the other handling web socket requests. We are going to use those particular two because there are two existing .NET classes, which can be used on desktop client side, HttpClient for working with http, and ClientWebSocket for working with web sockets. I was wondering whether to create few more services, one working with messaging queue like RabbitMQ or ZeroMQ or some similar Apache stuffs, or to create something working with gRPC or similar. Most of the message solutions require separate installation of message server (ZeroMQ does not by the way), so they are not OK for this demo. gRPC requires .NET Core for client side, so we skip it as well for this demo solution. So, without further ado, let's go to...

Creating Folder for Services Solution and Installing Needed npm Packages

At this point, we assumed that node.js is installed without issues. I have created a folder called devnodejs on my D: drive and in this folder I have created another, called moduleDemosSimplifed. So, with those folders created, we start the Node.js command prompt, which is a normal command prompt with some variables set for working with node.js and we navigate to the folder we just created. We keep the prompt open and we start Visual Studio Code or Atom. From now on, I will refer only to Visual Studio Code editor as editor, but you can use whatever you like. I forgot which plugins I have installed for my editor in order to show http and JavaScript nicely, so you have to figure this out yourself. So, after we start the code editor, we open our folder by selecting File -> Open Folder. We should see something like that:

Then, we go back to node.js prompt and type npm init. Ho ho, no, we don't. I'm just kidding. We just type npm install express and then npm install body-parser. This is how the node.js prompt looks after that:

We go back to editor and we create our first file, httpServer.js. We are going to change its content in the following sections, but for a start, we need to test our system. We type the following code in the file. Pay attention that I don't use semicolons for ending the lines, because it is not needed. But at the end, it's up to you. Me, for example, when I write client side JavaScript, I put semicolons. Here, I don't.

Back to node.js console, we type node httpserver.js and we expect to see the confirmation console message we just created few lines above in the editor. Now, without canceling execution on our script by node.js, we go and open our browser. Or, if we have more than one, we open those of them who is recently updated and is working fine. Then, we type the following in address bar: http://localhost:3001 and confirm.

If we see our message from node.js server file, Hello, server is working in my case, then we are good for the next step. If we look back to node.js console, we will see long text, which is the parsed incoming message.

Simulating Some Server Logic - Usage of node.js Modules

We are going to create a file, called business.js, which will contain one method called getFigure. This method will create an object and it will randomly assign one enum and one string property. We are going to use this method in our httpServer.js and later in wsServer.js and we will export this method not with its' name, but with another - getInfo. So here is the code for business.js. What might be interesting here is that we create random numbers in two ways - one, using the default JavaScript Random and the other, using the node.js crypto library randomFillSync. In production environment, I would suggest that you use the randomFill. It is not used here because it requires a callback and because the sync method is performing quite well, even when I make lots of requests, from five clients making 2 000 or 50 000 requests almost simultaneously.

httpServer - Implemeting Post Request Handling and Make Basic Testing

We select node.js console and press Ctrl+C to stop execution. Then we type cls. We are going to change the server JavaScript file, so after our changes, we need to start node execution again. There are few npm packages that can do this automatically and save your some time, you can install them if you want. In this tutorial, we don't.

We modify the existing httpServer.js and add some post handling methods. Those methods will be used later by our client - it will call them and it will receive information back from httpServer. This is how the final httpServer.js looks like:

You can comment/uncomment the console logging to have some information, this will be very useful when you develop futher. Now, it is time to start again the server by typing node httpServer.js in node.js console and confirm. After that, we should see again the console message confirming that server is running. We are going to use Postman to do basic testing of post requests. There are other tools available as well, I think you can do this with Fiddler as well, and maybe more tools which I do not recall at the moment. When Postman window loads, we will make a GET request to see whether all is good, the same thing we did earlier with browser. So we select GET from postman dropdown, type http://localhost:3001 and confirm. We should see the same message,"Hello, server is working", returned. If all good, we are going to make our POST request. So, this time we select POST from dropdown menu and specify http://localhost:3001/ping in Postman text box. Then, we click on Body tab and we enter text formatted as json - we specify JSON (application/json) from another dropdown, just below Body tab. See the screenshot:

We press then Send button and it should return again json, and this time, the value should be "Pong". Then, we can try another thing, change the value in body to something else, like something else and again press Send. Here is the result:

This all might seem quite easy, but it is very important. Try to misformat json message and you will get server exception. Try to log the parsed message in console to see how node.js colors the json objects which are treated as objects. If you send json object, but it is treated as string by node.js (by your logic actually), it will be displayed in a different manner.

wsServer Implementation

While httpServer.js is running in its node.js console, we start another node.js console and navigate to the same folder as before. We are going to use the same business logic, but this time, we will create a server, which will listen for web socket messages. For working with web sockets we are going to use ws module. This is how we install it:

and this is the wsServer.js code, together with final server solution structure:

When we are happy with code, we can start executing it as well. We type node wsServer.js in the second node.js console (the first one means this running the httpServer) and confirm. The notification message appears in console. wsServer is up and running. We are going to leave both node.js console running and we are going to proceed to part three of the article. We will assume that those two servers run always without issues for the remaining parts of article. If some exception occurs during development, we should restart them accordingly.

Final words for this part - I have tried to make the server part simple. Server code is usually more complex and it accounts for lots of things - how the service is hosted, clustering, other modules, authentication, authorization, etc. etc. etc., which goes outside the scope of our article. The good thing is that node.js development comes naturally and there are plenty of resources and sample code, so if you go the node.js way, I think you go right way. When I use node.js everything comes quite easy and I feel quite happy. For comparison, I always feel angry at some point when I read documentation prepared by some big companies on similar topics.

Back to Client - Time to Start Communicating With Services and Make Skia Drawings

In this part, we are going to use HttpClient and ClientWebSocket classes to make http and web socket requests to the services, which we just created in part two. We have few goals in this chapter, so let's summarize them:

  • Implement several custom console commands, so that we are able to more complex tasks - call http service, connect to ws service, call it, close the connection, etc. etc., change authorities for services url, so that when we test the client from other machine over the network or web, we can easily change the default values, which we are going to set. Because this is a demo application, we are not going to use any config files. If you go further with this, I would advise you to use config files and not to hardcode the addresses like we do here.
  • The nature of web sockets requires first to be established connection (which is made by sending http... actually you can search the web for a description how web sockets work in details, here we skip it) and when this connection is established, messages between parties are send using web socket protocol. So then comes naturally that we want to have a separate thread, dealing only with this - web sockets. We implement it here by using an infinite loop, which we break only when we close the connection or close the application. For handing all this stuff easily, we will make an additional enum, ThreadedWebSocketState.
  • When working with web socket messages, we are going to use small class for messages, called WebSocketMessage. It will have only two properties, a string one called Method name and an object, called Data. Data will be actually a json string, which we later cast to other objects.
  • The way Skia is ported to WPF does not allow usage of bindings. So we are going to use code-behind approach and the OnPaintSurface event and we will draw our stuffs in this event helper method. We are going to use a ConcurrentBag for adding objects and later make drawings based on them on skia canvas. So we will also implement method for calling InvalidateVisual of skia element, and for clearing all objects currently added to bag. We will have to tune a little bit our navigation and ConsoleSimplifiedViewModel to handle all of those.
  • We will change the code-behind of ConsoleSimplifiedView a little bit more to allow text box, where we type all commands, to be selected when the user control is loaded. This is due to a trick, which is quite good explained in one CodeProject article by different author. Link to the article is placed as a comment in code itself.
  • We want to have some graphical representation of data, coming from our "business" logic. There, we create a random enum, which will determine what figure we draw, and the next thing which is provided by service logic is a 60 (n) characters long random string. We are going to take the first 30 (n/2) characters and transform them to a number between 0 and 1, and we are going to do the same with remaining 30 (n/2) characters. So we will have a normalized x and y coordinates and we can draw a point easily.

So, let's get started with part three of our article.

Installing Additional NuGets and Creating Few Classes and Enums

Following the instructions of installing NuGet packages in part one, we are going to install three more in order to work with json and with Skia. Here is how installed packages tab looks like after we installed all of those three packages:

Don't mind ControlzEx, it is required by MahApps. Build and run the client to make sure everything is fine and it is working properly.

We right click Models folder and add new class called WebSocketMessage. It looks like that:

Then, we create the enum ThreadedWebSocketState:

Then, we create the enum FigureKinds:

Then, we create the class Figure. Build and run to make sure we are good.

Updating the Navigation View Model and Console Simplified (View, Code-Behind and View Model)

Ok, we have come to this point, which is great. Soon, we are going to start making calls to services and using the demo solution. Classes below are important for the sample solution, so please make sure all is good for you and you are happy with them. Please, ask if something should be explained further. All of the presented class and markup files are final versions.

This is how the XAML for ConsoleSimplifiedView looks like (with some graphical explanations):

This is now the code-behind for the same class that looks like:

using LocalBrowser.Models;
using SkiaSharp;
using SkiaSharp.Views.Desktop;
using System;
using System.Collections.Concurrent;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;

namespace LocalBrowser
{
    public partial class ConsoleSimplifiedView : UserControl
    {
        public ConsoleSimplifiedView()
        {
            InitializeComponent();
            IsVisibleChanged += OnIsVisibleChanged;
            skiaElement.PaintSurface += OnPaintSurface;
            Figures = new ConcurrentBag<Figure>();
        }

        public ConcurrentBag<Figure> Figures { get; private set; }

        public void ScrollToEnd()
        {
            scroll.ScrollToEnd();
        }

        public void SkiaInvalidateVisual()
        {
            App.Current.Dispatcher.InvokeAsync(() =>
            {
                skiaElement.InvalidateVisual();
            });
        }

        public void ClearSkia()
        {
            Figures = new ConcurrentBag<Figure>();
            skiaElement.InvalidateVisual();
        }

        private void OnPaintSurface(object sender, SKPaintSurfaceEventArgs e)
        {
            SKCanvas canvas = e.Surface.Canvas;
            canvas.Clear();

            float width = (float)skiaElement.ActualWidth;
            float height = (float)skiaElement.ActualHeight;
            SKPoint cente = new SKPoint(width / 2, height / 2);

            SKPoint point;
            SKPaint circlePaint = new SKPaint() { Style = SKPaintStyle.StrokeAndFill, 
                                                  IsAntialias = true, Color = SKColors.OrangeRed };
            SKPaint rectanglePaint = new SKPaint() { Style = SKPaintStyle.StrokeAndFill, 
                                                     IsAntialias = true, Color = SKColors.BlueViolet };
            SKPaint squarePaint = new SKPaint() { Style = SKPaintStyle.StrokeAndFill, 
                                                  IsAntialias = true, Color = SKColors.DarkSeaGreen };

            foreach (Figure figure in Figures)
            {
                point = new SKPoint(figure.NormalX * width, figure.NormalY * height);
                if (figure.Kinds.HasFlag(FigureKinds.circle))
                {
                    canvas.DrawCircle(point, 4, circlePaint);
                }

                if (figure.Kinds.HasFlag(FigureKinds.rectangle))
                {
                    canvas.DrawRect(point.X, point.Y, 10, 5, rectanglePaint);
                }

                if (figure.Kinds.HasFlag(FigureKinds.square))
                {
                    canvas.DrawRect(point.X, point.Y, 7, 7, squarePaint);
                }
            }
        }

        private void OnIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
        {
            // Only to focus the text box. 
            // Thanks to https://www.codeproject.com/Tips/
            // 478376/%2FTips%2F478376%2FSetting-focus-to-a-control-inside-a-usercontrol-in
            if ((bool)e.NewValue == true)
            {
                Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(() =>
                {
                    txt.Focus();
                }));
            }
        }
    }
}

So, this is how we change the ConsoleSimplifiedViewModel. This is the final version:

using LocalBrowser.Models;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;

namespace LocalBrowser
{
    public class ConsoleSimplifiedViewModel : ViewModelBase
    {
        private string userInput, httpAuthority, wsAuthority;
        private ConsoleViewModel consoleViewModel;
        private ConsoleSimplifiedView view;
        private Stack<string> lifo;

        private ThreadedWebSocketState state = ThreadedWebSocketState.None;
        private bool consoleLogging = false;

        private WebSocketMessage webSocketMessage;

        public ConsoleSimplifiedViewModel(ConsoleSimplifiedView view)
        {
            this.view = view; // skia user control does not support binding, 
                              // so we deal with it like this :)
            lifo = new Stack<string>();

            httpAuthority = "http://localhost:3001";
            wsAuthority = "ws://localhost:3004";

            ProceedUserInputCommand = new SimpleCommand(ProceedUserInput, CanProceedUserInput);
            ProceedPreviousCommand = new SimpleCommand(ProceedPrevious);
            ConsoleViewModel = new ConsoleViewModel();
            ConsoleViewModel.ImplementedConsoleCommands.Add("shutdown", ShutdownApp);
            ConsoleViewModel.CommandDescriptions.Add("shutdown", "Closes the entire application");

            ConsoleViewModel.ImplementedConsoleCommands.Add("toggledetails", ToggleLogging);
            consoleViewModel.CommandDescriptions.Add("toggledetails", 
                                    "Controls showing of additional information");
            ConsoleViewModel.ImplementedConsoleCommands.Add("clearskia", ClearSkia);
            ConsoleViewModel.CommandDescriptions.Add("clearskia", "Clear the skia surface");

            ConsoleViewModel.ImplementedConsoleCommands.Add("pinghttp", PingviaHttp);
            ConsoleViewModel.CommandDescriptions.Add("pinghttp", string.Format
                                 ("Ping the http server at {0}/ping", httpAuthority));
            ConsoleViewModel.ImplementedConsoleCommands.Add("callhttp", CallNodeViaHttp);
            ConsoleViewModel.CommandDescriptions.Add("callhttp", string.Format("Make http POST 
                request to {0}/figure. Typing callhttp 10 will make 10 requests", httpAuthority));

            ConsoleViewModel.ImplementedConsoleCommands.Add("startws", StartWebSocketMode);
            ConsoleViewModel.CommandDescriptions.Add("startws", string.Format
                                    ("Creates a connection to {0}", wsAuthority));
            ConsoleViewModel.ImplementedConsoleCommands.Add("stopws", StopWebSocketMode);
            ConsoleViewModel.CommandDescriptions.Add("stopws", string.Format
                                    ("Closes an existing connection to {0}", wsAuthority));
            ConsoleViewModel.ImplementedConsoleCommands.Add("callws", CallNodeViaWebSocket);
            ConsoleViewModel.CommandDescriptions.Add("callws", string.Format
                                    ("Send message requesting fugure to {0}", wsAuthority));
            ConsoleViewModel.ImplementedConsoleCommands.Add("pingws", PingNodeViaWebSocket);
            ConsoleViewModel.CommandDescriptions.Add("pingws", string.Format
                                    ("Send message requesting ping back to {0}", wsAuthority));

            ConsoleViewModel.ImplementedConsoleCommands.Add("changeauthority", ChangeAuthority);
            ConsoleViewModel.CommandDescriptions.Add("changeauthority", 
                           "Changes the authority. Arguments are schema host port. 
                            Example: changeauthority ws 192.168.0.1 1234");
            ConsoleViewModel.ImplementedConsoleCommands.Add("showauthorities", ShowAuthorities);
            ConsoleViewModel.CommandDescriptions.Add("showauthorities", "Shows current authorities");
        }

        public ICommand ProceedUserInputCommand { get; private set; }
        public ICommand ProceedPreviousCommand { get; private set; }

        public string UserInput
        {
            get { return userInput; }
            set
            {
                userInput = value;
                OnPropertyChanged("UserInput");
            }
        }

        public ConsoleViewModel ConsoleViewModel
        {
            get { return consoleViewModel; }
            set
            {
                consoleViewModel = value;
                OnPropertyChanged("ConsoleViewModel");
            }
        }

        private bool CanProceedUserInput(object parameter = null)
        {
            return (string.IsNullOrEmpty(UserInput.Trim()) == false);
        }

        private void ProceedUserInput(object parameter = null)
        {
            ConsoleViewModel.ProceedConsoleMessage(UserInput);
            view.ScrollToEnd();
            lifo.Push(UserInput);
            UserInput = string.Empty;
        }

        private void ProceedPrevious(object parameter = null)
        {
            if (lifo.Count > 0)
            {
                UserInput = lifo.Pop();
            }
            else
            {
                UserInput = string.Empty;
            }
        }

        private void ToggleLogging()
        {
            consoleLogging = !consoleLogging;
            ConsoleViewModel.ProceedConsoleMessage(string.Format
                   ("Console logging was set to: {0}", consoleLogging ? "On" : "Off"));
        }

        private void ClearSkia()
        {
            view.ClearSkia();
        }

        private void ChangeAuthority()
        {
            if (ConsoleViewModel.ArgumentsInUse == null || 
               String.IsNullOrEmpty(ConsoleViewModel.ArgumentsInUse[0]) || 
                ConsoleViewModel.ArgumentsInUse.Length != 3)
            {
                ConsoleViewModel.ProceedConsoleMessage
                       ("This commands requires three arguments in following succession: 
                             schema(ws or http) host port");
            }
            else
            {
                if (ConsoleViewModel.ArgumentsInUse[0].ToLowerInvariant().Equals("ws"))
                {
                    string wsAuthorityCandidate = string.Format("ws://{0}:{1}", 
                      ConsoleViewModel.ArgumentsInUse[1], ConsoleViewModel.ArgumentsInUse[2]);
                    if (Uri.IsWellFormedUriString(wsAuthorityCandidate, UriKind.Absolute))
                    {
                        wsAuthority = wsAuthorityCandidate;
                    }
                    else
                    {
                        ConsoleViewModel.ProceedRunOnly("The resulting Uri", 
                                              ConsoleViewModel.Foreground, null);
                        ConsoleViewModel.ProceedRunOnly(string.Format(" {0} ", 
                                     wsAuthorityCandidate), ConsoleViewModel.Background, 
                                     ConsoleViewModel.Foreground);
                        ConsoleViewModel.ProceedRunOnly(string.Format
                           (" does not appear to be valid. Please, enter proper arguments.{0}", 
                           Environment.NewLine), ConsoleViewModel.Foreground, null);
                    }
                }
                else if (ConsoleViewModel.ArgumentsInUse[0].ToLowerInvariant().Equals("http"))
                {
                    string httpAuthorityCandidate = string.Format("http://{0}:{1}", 
                       ConsoleViewModel.ArgumentsInUse[1], ConsoleViewModel.ArgumentsInUse[2]);
                    if (Uri.IsWellFormedUriString(httpAuthorityCandidate, UriKind.Absolute))
                    {
                        httpAuthority = httpAuthorityCandidate;
                    }
                    else
                    {
                        ConsoleViewModel.ProceedRunOnly
                                      ("The resulting Uri", ConsoleViewModel.Foreground, null);
                        ConsoleViewModel.ProceedRunOnly(string.Format(" {0} ", 
                              httpAuthorityCandidate), ConsoleViewModel.Background, 
                                    ConsoleViewModel.Foreground);
                        ConsoleViewModel.ProceedRunOnly(string.Format
                                (" does not appear to be valid. Please, 
                                   enter proper arguments.{0}", Environment.NewLine), 
                                   ConsoleViewModel.Foreground, null);
                    }
                }
            }
        }

        private void ShowAuthorities()
        {
            ConsoleViewModel.ProceedConsoleMessage(string.Format
               ("Current authorities: {0}{1}{0}{2}", Environment.NewLine, httpAuthority, wsAuthority));
        }

        private void ShutdownApp()
        {
            System.Windows.Application.Current.Shutdown();
        }

        private void CallNodeViaHttp()
        {
            string requestUri = string.Format("{0}/figure", httpAuthority);
            if (ConsoleViewModel.ArgumentsInUse == null || String.IsNullOrEmpty
                                    (ConsoleViewModel.ArgumentsInUse[0]))
            {
                ThreadPool.QueueUserWorkItem(o =>
                {
                    CallNodeViaHttp(requestUri, string.Empty);
                });
            }
            else
            {
                int loop;
                if (Int32.TryParse(ConsoleViewModel.ArgumentsInUse[0], out loop))
                {
                    loop = Math.Abs(loop);
                    Parallel.For(0, loop, o =>
                    {
                        ThreadPool.QueueUserWorkItem(bo =>
                        {
                            CallNodeViaHttp(requestUri, string.Empty);
                        });
                    });
                }
            }
        }

        private void PingviaHttp()
        {
            string json = JsonConvert.SerializeObject(new { ping = "Ping" });
            ThreadPool.QueueUserWorkItem(o =>
            {
                CallNodeViaHttp(string.Format("{0}/ping", httpAuthority), json);
            });
        }

        private async void CallNodeViaHttp(string requestUri, string json)
        {
            using (HttpClient httpClient = new HttpClient())
            {
                try
                {
                    HttpResponseMessage response = await httpClient.PostAsync
                          (requestUri, new StringContent(json, Encoding.UTF8, "application/json"));
                    if (response.StatusCode.Equals(HttpStatusCode.OK))
                    {
                        string fromServer = await response.Content.ReadAsStringAsync();
                        if (requestUri.Equals(string.Format("{0}/figure", httpAuthority)))
                        {
                            Figure httpFigure = JsonConvert.DeserializeObject<Figure>(fromServer);
                            httpFigure.Min = 1750;
                            httpFigure.Max = 2450;
                            httpFigure.CalculateNormals();
                            if (consoleLogging)
                            {
                                ConsoleViewModel.ProceedSystemMessage(string.Format
                                ("Received a figure: {1} Data: {2} Length: {3}{0}Normal 
                                    X:{4} Normal Y: {5} Max: {6} Min: {7}",
                                    Environment.NewLine, httpFigure.Kinds, httpFigure.Data, 
                                    httpFigure.Data.Length, httpFigure.NormalX, 
                                    httpFigure.NormalY, httpFigure.Max, httpFigure.Min), false);
                            }
                            view.Figures.Add(httpFigure);
                            view.SkiaInvalidateVisual();
                        }
                        else if (requestUri.Equals(string.Format("{0}/ping", httpAuthority)))
                        {
                            ConsoleViewModel.ProceedSystemMessage(string.Format
                               ("We asked server: Ping. Server answered: {0}", fromServer), false);
                        }
                    }
                }
                catch (HttpRequestException hre)
                {
                    ConsoleViewModel.ProceedSystemMessage(string.Format
                            ("There has been an underlying issue.{0} Details: {1}", 
                              Environment.NewLine, hre.Message));
                }
                catch (Exception)
                {
                    throw;
                }
            }
        }

        private void StartWebSocketMode()
        {
            ThreadPool.QueueUserWorkItem(omicron =>
            {
                TaskWebSocket();
            });
        }

        private void CallNodeViaWebSocket()
        {
            if (state.Equals(ThreadedWebSocketState.Created))
            {
                if (ConsoleViewModel.ArgumentsInUse == null || String.IsNullOrEmpty
                                  (ConsoleViewModel.ArgumentsInUse[0]))
                {
                    webSocketMessage = new WebSocketMessage() { Method = "figure", Data = 0 };
                }
                else
                {
                    int loop;
                    if (int.TryParse(ConsoleViewModel.ArgumentsInUse[0], out loop))
                    {
                        webSocketMessage = new WebSocketMessage() 
                               { Method = "figure", Data = Math.Abs(loop) };
                    }
                }
            }
            else
            {
                ConsoleViewModel.ProceedSystemMessage("Please connect to web socket server first.");
            }
        }

        private void PingNodeViaWebSocket()
        {
            if (state.Equals(ThreadedWebSocketState.Created))
            {
                webSocketMessage = new WebSocketMessage() { Method = "ping" };
            }
            else
            {
                ConsoleViewModel.ProceedSystemMessage("Please connect to web socket server first.");
            }
        }

        private void StopWebSocketMode()
        {
            if (state.Equals(ThreadedWebSocketState.Created))
            {
                webSocketMessage = new WebSocketMessage() { Method = "stop" };
            }
            else
            {
                ConsoleViewModel.ProceedSystemMessage("Please connect to web socket server first.");
            }
        }

        private async void TaskWebSocket()
        {
            using (ClientWebSocket ws = new ClientWebSocket())
            {
                try
                {
                    state = ThreadedWebSocketState.Created;
                    Uri wsUri = new Uri(wsAuthority);
                    await ws.ConnectAsync(wsUri, CancellationToken.None);
                    ConsoleViewModel.ProceedSystemMessage
                          (string.Format("Connected to {0}", wsAuthority), false);
                }
                catch (WebSocketException wse)
                {
                    ConsoleViewModel.ProceedSystemMessage(string.Format
                       ("There has been an underlying issue.{0} Details: {1}", 
                         Environment.NewLine, wse.Message));
                }
                catch (Exception)
                {
                    throw;
                }

                while (ws.State == WebSocketState.Open)
                {
                    if (string.IsNullOrEmpty(webSocketMessage?.Method) == false)
                    {
                        if (webSocketMessage.Method.Equals("stop"))
                        {
                            await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, 
                                             "drun drun", CancellationToken.None);
                            webSocketMessage = null;
                            state = ThreadedWebSocketState.Closed;
                            ConsoleViewModel.ProceedSystemMessage
                                ("Collection to websocket server was closed", false);
                            break;
                        }
                        else
                        {
                            byte[] message = Encoding.UTF8.GetBytes
                                          (JsonConvert.SerializeObject(webSocketMessage));
                            await ws.SendAsync(new ArraySegment<byte>(message), 
                                    WebSocketMessageType.Text, true, CancellationToken.None);
                            ArraySegment<byte> received = new ArraySegment<byte>(new byte[1024]);
                            WebSocketReceiveResult result = await ws.ReceiveAsync
                                                  (received, CancellationToken.None);
                            WebSocketMessage messageBack = 
                                     JsonConvert.DeserializeObject<WebSocketMessage>
                                             (Encoding.UTF8.GetString(received.Array));

                            if (webSocketMessage.Method.Equals("ping"))
                            {
                                ConsoleViewModel.ProceedSystemMessage(string.Format
                                ("App to node.js: ping: node.js to app: {0}", messageBack.Data), false);
                                webSocketMessage = null;
                            }
                            else if (webSocketMessage.Method.Equals("figure"))
                            {
                                ProcessFigure(messageBack);
                                webSocketMessage.Data = Math.Max(0, (int)webSocketMessage.Data - 1);
                                if ((int)webSocketMessage.Data == 0)
                                {
                                    view.SkiaInvalidateVisual();
                                    webSocketMessage = null;
                                }
                            }
                        }
                    }
                }
            }
        }

        private void ProcessFigure(WebSocketMessage messageBack)
        {
            Figure wsFigure = JsonConvert.DeserializeObject<Figure>(messageBack.Data.ToString());
            wsFigure.Min = 1750;
            wsFigure.Max = 2450;
            wsFigure.CalculateNormals();

            if (consoleLogging)
            {
                ConsoleViewModel.ProceedSystemMessage(string.Format("Received a figure: 
                  {1} Data: {2} Length: {3}{0}Normal X:{4} Normal Y: {5} Max: {6} Min: {7}",
                    Environment.NewLine, wsFigure.Kinds, wsFigure.Data, wsFigure.Data.Length, 
                    wsFigure.NormalX, wsFigure.NormalY, wsFigure.Max, wsFigure.Min), false);
            }
            view.Figures.Add(wsFigure);
        }
    }
}

and this is the navigation view model, the ShellViewModel.

At this point, you build and run the solution. If all goes well, we are ready for next steps - calling the services and explaining what the demo does. If there are troubles building and running the solution, you have to troubleshoot and eventually fix it. From this point forward, there is no more code written, we are going to call services, see how Skia draws the result and discuss on some approached and motivations.

Using the Demo - Typing Command and Watching Small Circles, Rectangles and Squares

We have built such a good demo solution and now it is time to use it. So we start the client, we navigate to view, start typing without clicking anywhere and type help. We will see a list of all implemented commands - pair of one word and set of other words, describing what will happen if we type the word and confirm. We know from part one how to change the font and background colors, so we can use this knowledge as well. Then, we start working with services.

Using httpServer.js Service

We type pinghttp and if the service we created in part two and leaved working, is still working, we should see response message. Then, we type toggleDetails and we should see information about changed state in our console like text block. Then we type callhttp and confirm. We have something like this:

We hit the case where all three figures - circle, rectangle and square are generated. The random string is as you can see, and with our custom normalization methods, we receive from this random string two x and y normalized coordinates. Skia uses them and make the drawings. Let's make twenty more calls. Now we have this situation. We can check that the set of figures and generated strings are different every time and because of that, we have different figures at different points on canvas:

Using wsServer.js Service

Ok, now we type clear to clear the console screen and then type clearskia to clear skia canvas. Then, we type startws to establish connection to web socket server. Then, we type callws. Because we set the console logging to on, we will see detailed information again. Ok, let's call it nine more times for total of ten.

Reaching the Limits of Demo Solution

Ok, now it is time to play a little bit more. We can start several clients and make large number of requests from them, both to http and ws servers. At some point, depending on our hardware, we are going to reach the limits and encounter some exceptions. Here is a quick comparison between http and ws calls. You might ask: what's the difference between http calls that HttpClient makes and web socket calls what ClientWebSocket made? There are lots of differences, one call bears one information and the other another, beside the business one, which is basically the same. Or you can start a release version of client and start making large number of calls until you see something like that:

We have encountered an HttpRequestException, because we're creating so many HttpClients so that client UI thread making so many calls so that server cannot process them and eventually hangs. I'm using a Xeon server processor with two sockets and thirty-two logical processors running on @2.6GHz, RAM is big enough, 48 GB, but it is DDR3 and it's working on 1 333 MHz. For comparison, I have other machine with Core i7 CPU with eight processors, running on @4.2 GHz, with less memory, 32GB, but it's DDR5 on 2 933 MHz, so there i make 50 000 http calls and no such errors occurs. It occurs when I increase the number.

Now, let's see how web socket server will perform. We clear both console and skia canvas and type startws. Then, we make 5000 ws calls. No exception and it's faster. Because in our code, we update the canvas after all requests are made, we can look at empty canvas for one or two seconds. OK, now let's make 50 000 calls. Still no exception. See the result:

While I was making this demo solution, I was not able to reach the limit of making web socket calls, maybe because I did not used more instances of demo client and making more calls. I was hoping that at some point, I can reach the limitations posed by randomFillSync, which I used in server logic, but I did not.

I Hope You Enjoyed This Article the Way I Did When Making It

It has some design compromises, but still I guess for demo solution is quite OK. I hope someone might find this interesting. The next steps are probably going to cluster the httpServer and see how this can improve the number of requests handled before getting HttpRequestException.

License

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

Share

About the Author

No Biography provided

You may also be interested in...

Pro
Pro

Comments and Discussions

 
QuestionHttpRequestException - HttpClients Pin
kiquenet.com4-Feb-19 1:55
professionalkiquenet.com4-Feb-19 1:55 
AnswerRe: HttpRequestException - HttpClients Pin
Издислав Издиславов13-Feb-19 13:40
memberИздислав Издиславов13-Feb-19 13:40 

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.

Permalink | Advertise | Privacy | Cookies | Terms of Use | Mobile
Web04 | 2.8.190425.1 | Last Updated 2 Feb 2019
Article Copyright 2019 by Издислав Издиславов
Everything else Copyright © CodeProject, 1999-2019
Layout: fixed | fluid