Skip to content

jamesnet214/riotslider

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

40 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Introduction

M12 2C11.5 2 11 2.19 10.59 2.59L2.59 10.59C1.8 11.37 1.8 12.63 2.59 13.41L10.59 21.41C11.37 22.2 12.63 22.2 13.41 21.41L21.41 13.41C22.2 12.63 22.2 11.37 21.41 10.59L13.41 2.59C13 2.19 12.5 2 12 2M12 4L15.29 7.29L12 10.59L8.71 7.29L12 4M7.29 8.71L10.59 12L7.29 15.29L4 12L7.29 8.71M16.71 8.71L20 12L16.71 15.29L13.41 12L16.71 8.71M12 13.41L15.29 16.71L12 20L8.71 16.71L12 13.41Z

Pictogrammers(rhombus-split-outline)

WechatIMG12

Analyzing and Customizing the Detailed Mechanisms of WPF Slider Control

Among WPF's basic controls such as Button, CheckBox, and ToggleButton, the structure is relatively straightforward, enabling complete implementation through XAML without code-behind. In contrast, controls like TextBox, ComboBox, and Slider necessitate intricate handling through both XAML and C# code. A solid understanding of these controls' fundamental CustomControl structures proves incredibly beneficial when customizing afresh.

This time, we will delve deeply into the internal mechanisms of WPF's standard Slider control. While it's not essential to study the internal structure of every control, it's advantageous to analyze the relevant controls from the GitHub repository when needed, as WPF's source code is openly available there.

Moving forward, we plan to dissect and analyze various controls, not just the Slider. We appreciate your continued interest and support for our GitHub repository, CodeProject articles, and tutorial videos provided on YouTube and BiliBili.

20240201163433798

Contents

  1. WPF Tutorial Series
  2. Specification
  3. Creating an Application Project
  4. Analyzing the Main Features of Slider
  5. Extracting the Original Style Process
  6. Analysis of Extracted Source Code
  7. Checking Code Behind (GitHub Open Source)
  8. OnApplyTemplate in Cross-Platform
  9. Concluding the Slider Analysis
  10. Creating a Riot-Style Slider (CustomControl) Control
  11. Project Creation and Preparation for Start
  12. TextBlock (Hi Slider)
  13. Adding References and Testing Execution
  14. Setting the Size of Riot Slider
  15. PART_Track
  16. Adding the Slider Bar
  17. Aligning the Gap Between Slider Bar and Track
  18. PART_SelectionRange
  19. Adding Riot-Style Design Elements
  20. Implementing a Riot-Style Thumb
  21. Declaring Thumb Resources
  22. Completing the RiotSlider Template (Finishing Touches)
  23. Final Remarks

WPF Tutorial Series

To date, four tutorial series have been released on YouTube and BiliBili. These videos are available in English and Chinese, with Korean subtitles on YouTube. We hope these videos, through sophisticated source code and detailed expert explanations, will enhance your understanding of WPF.

Specifications:

This project is based on .NET Core but is designated for Windows only due to the use of WPF. It is executable through VS2022, which is mandatory for running NET 8.0. Alternatively, JetBrains' Rider can also be used.

  • OS: Microsoft Windows 11
  • IDE: Microsoft Visual Studio 2022
  • Version: C# / NET 8.0 / WPF / windows target only
  • NuGet: Jamesnet.Wpf

Using the latest version of Windows as your operating system is recommended. However, if you are considering platform expansion to Avalonia UI, Uno Platform, MAUI, etc., it's also worth considering MacOS as a sub-device. We are using Thinkpad/Macbooks as well. Note that Visual Studio is not available on MacOS or Linux-based systems, so Rider is the only alternative. vscode

3. Creating an Application Project

To get started, you first need to create a WPF Application project.

  • Project Type:WPF Application
  • Project Name: DemoApp
  • Project Version: .NET 8.0

4. Analyzing the Main Features of Slider

The WPF Slider control, unlike simpler controls such as Button, has a variety of properties. These properties play crucial functional roles in the control, and some operate in unique ways, making them particularly worthy of attention.

Orientation:

Controls in WPF often have a versatile nature, and the Orientation property of the Slider control is a prime example. This property allows for specifying the direction as either horizontal or vertical.

The Orientation property can also be found in the StackPanel control. While the default value of Orientation in StackPanel is Vertical, the default for Slider's Orientation is Horizontal. Thus, it is common to use the Slider in a Horizontal format, which might be why the Orientation feature is not widely known.

Let's take a closer look at a simplified part of the Slider to better understand Orientation:

<Style TargetType="{x:Type Slider}">
    <Setter Property="Template" Value="{StaticResource SliderHorizontal}"/>
    <Style.Triggers>
        <Trigger Property="Orientation" Value="Vertical">
            <Setter Property="Template" Value="{StaticResource SliderVertical}"/>
        </Trigger>
    </Style.Triggers>
</Style>

You can see that the (ControlTemplate) template switches based on the Orientation property in the trigger. Thus, a closer look at the actual configuration of this control can easily illustrate the significant role of the Orientation property.

It's an interesting part. Could you have imagined or applied the concept of switching templates through Orientation before seeing the original source? Open source can inspire in such ways. And let's note that the optimal timing for switching templates is indeed through the "Style.Trigger".

For this tutorial video, we will only implement the Horizontal direction, so we will not perform any branch switching through Orientation. However, you are encouraged to try creating a Vertical version and submit a Pull Request via Fork. Consider it a mission.

Let's also take a look at how the Horizontal/Vertical properties are applied:

  • Orientation: Horizontal

The SelectionRange (blue) area that will be discussed below is also visible.

  • Orientation: Vertical

Similarly, you will find quite a few controls that switch the (ControlTemplate) template itself in a similar manner (e.g., ScrollViewer).

Minimum, Maximum, and Value:

These are double type properties that represent the minimum range, maximum range, and value, respectively. Internally, the control's size and ratio calculate the position of the Range and Value automatically based on these values.

Since all these properties are DependencyProperty, dynamic interactions through binding are possible. For example, in an MVVM structure, leveraging these three values allows for dynamic changes to the Range according to specific scenarios or enables interesting implementations through various applications.

SelectionStart, SelectionEnd, and IsSelectionRangeEnabled:

These two properties (SelectionStart/SelectionEnd) serve to set a specific area. In reality, this area doesn't include any special functionality; it's merely for designating a segment and visually highlighting it. IsSelectionRangeEnabled is a property that indicates whether this area is active, and depending on its activation status, the area's Visibility property value switches through a trigger (Visible/Collapsed).

Upon examination, these features might seem merely for area marking, leading to questions about their necessity. However, given their versatile use across designs and fields, understanding and anticipating their necessity is possible. Respecting style preferences from 20 years ago

Interestingly, applying these with the Value can produce a fascinating effect as shown below:

<Slider Orientation="Horizontal"
        Minimum="0"
        Maximum="100"
        Value="30"
        SelectionStart="0"
        SelectionEnd="{Binding Value, RelativeSource={RelativeSource Self}}"
        IsSelectionRangeEnabled="True"/>

Surprisingly, linking the Value to SelectionEnd through Binding allows for a dynamic change in the Selection (Range) as the value changes. Was this intended by the WPF developers? It's impressive, and the clean implementation method is quite satisfying.

This will play a crucial role in the implementation of the Riot-style Slider (CustomControl) discussed later in the article, so keep it in mind.

5. Extracting the Original Style Process

As mentioned earlier, since WPF is managed as open-source through the GitHub repository, it's possible to examine the source code of all controls. However, given that the repository contains solutions, all projects, and files, extracting content for a specific control part is a task close to impossible.

Fortunately, Visual Studio provides a GUI feature for extracting the default style (Template) of a specific control. Thus, without the need to sift through open-source, you can easily and simply extract the relevant code.

It's okay to think of this similar to Identity scaffolding in Blazor. (Though the nature is slightly different, it helps in understanding)

Moreover, extracting the original style through Visual Studio links you to an actual modifiable resource form, allowing for immediate customization of design and functionality. Therefore, since the original style and template extraction is possible not only for Slider but for all controls, this is a highly valuable element in WPF research/learning.

If you look at commercial components like Infragistics, Syncfusion, ArticPro, not all provide this extraction feature. Each company has its disclosure scope and policy, and most prefer to modularize via DataTemplate for customization rather than exposing the ControlTemplate. It's interesting to take a look at the components you are using.

Extraction Method and Procedure: Visual Studio
  • Extracting the default control (Slider) style (Edit a Copy...)
  • Extract to the current file (This document)
  • Extract to the App.xaml file (Application)
  • Create a new ResourceDictionary file for extraction (Resource Dictionary)

Note, the extraction process can only proceed in the design area of a Partial UserControl, by selecting the control and right-clicking to proceed. This step involves choosing the "specify style name/define copy location of the extracted style" option.

Try looking up the method in VScode or Rider, do they offer it?

Let's take a closer look at the process.

  • Style extraction command: Slider > Right click > Edit Template > Edit a Copy...
image

If no extractable style is provided, this item will not be activated.

  • Style Extraction Options Window: Create ControlTemplate Resource (Window)

Select Name (Key) and Define in options,

Typically, specifying a Name is the right choice for testing and management perspectives. If you choose "Apply to all" without specifying a name, the style created based on the Define location will be applied globally. Therefore, understand this point well and proceed with the extraction carefully.

In the video, the name is set, and the Define location is specified as Application. Thus, the extracted resource is included in the Resources area of the App.xaml file (if the file exists).

Personally, when performing such extraction work, it's recommended to proceed in a test nature in a new project. Actually conducting this process in a live project may result in minor mistakes and problems, so it's a good choice also from the perspective of preventing such side effects.

6. Analysis of Extracted Source Code

As demonstrated in the tutorial video, the Slider control style has been successfully extracted. Let's take a look at the related resources within the App.xaml file and examine the elements that are important to note one by one.

Checking Orientation Branch:

As briefly mentioned when explaining the Orientation property earlier, it's time to check the actual source code implemented.

The style below is the original WPF default style containing the extracted SliderStyle1 template. (It works without errors upon immediate application.)

<Style x:Key="SliderStyle1" TargetType="{x:Type Slider}">
    <Setter Property="Stylus.IsPressAndHoldEnabled" Value="false"/>
    <Setter Property="Background" Value="Transparent"/>
    <Setter Property="BorderBrush" Value="Transparent"/>
    <Setter Property="Foreground" Value="{StaticResource SliderThumb.Static.Foreground}"/>
    <Setter Property="Template" Value="{StaticResource SliderHorizontal}"/>
    <Style.Triggers>
        <Trigger Property="Orientation" Value="Vertical">
            <Setter Property="Template" Value="{StaticResource SliderVertical}"/>
        </Trigger>
    </Style.Triggers>
</Style>

From this, we can see that the default Template is set to the SliderHorizontal (ControlTemplate) template, and through a trigger, it switches to the SliderVertical (ControlTemplate) template when the Orientation property value is Vertical.

By modularizing the (ControlTemplate) template like this, you gain the advantage of being able to see the actual style at a glance, which is a management structure worth trying even in non-switching situations. I do it often. You can also get inspiration from these aspects.

Thus, the Slider control's functionalities are essentially implemented within both the SliderHorizontal and SliderVertical (ControlTemplate) areas.

Let's now check the default SliderHorizontal (ControlTemplate) template.

Checking ControlTemplate:

Let's examine each of the Horizontal/Vertical specific templates, which can be found continuously within the App.xaml file.

  • Check Horizontal specific template
  • Check Vertical specific template

ControlTemplate: SliderHorizontal

<ControlTemplate x:Key="SliderHorizontal" TargetType="{x:Type Slider}">
    <Border ...>
        ...
    </Border>
    <ControlTemplate.Triggers>
        ...
    </ControlTemplate.Triggers>
</ControlTemplate>

ControlTemplate: SliderVertical

<ControlTemplate x:Key="SliderVertical" TargetType="{x:Type Slider}">
    <Border ...>
		...
    </Border>
    <ControlTemplate.Triggers>
		...
    </ControlTemplate.Triggers>
</ControlTemplate>

As seen, both the Horizontal/Vertical source codes are branched and implemented separately. Therefore, the implemented content is the same for both, differing only in design orientation.

Let's verify this precisely. The common elements included are as follows:

  • Name: TopTick
  • Name: BottomTick
  • Name: TrackBackground
  • Name: PART_SelectionRange
  • Name: PART_Track
  • Name: Thumb
  • Trigger: TickPlacement
  • Trigger: IsSelectionRangeEnabled
  • Trigger: IsKeyboardFocused

We can see that the common elements are included in both ControlTemplates, confirming that both have the same composition. Now, let's focus on and examine only the SliderHorizontal part.

Naming rule: PART_

In the structure of (CustomControl) controls, maintaining a tight connection between XAML and Code-behind is crucial. However, connecting them through the GetTemplateChild method to find control names can be visually unappealing. To mitigate this development approach and manage it systematically, the PART_ naming rule is used.

This rule prefixes all control names found through GetTemplateChild with PART_, allowing you to guess the function in XAML. Thus, when analyzing (ControlTemplate) controls, discovering a control named starting with PART_ suggests it's likely an essential element, and you can anticipate the side effects that might occur if it's removed.

Ultimately, this is immensely helpful in implementing CustomControls. Moreover, this rule is common not only in WPF but also in other cross-platforms sharing XAML, emphasizing its importance.

Slider contains two PART_ controls.

  • PART_Track
  • PART_SelectionRange

Consequently, aside from these two PART_ controls, the rest are not used in Code-behind, ensured by this naming rule. Therefore, adhering strictly to this rule in CustomControl development is crucial.

Test: Check the impact after intentionally changing the name of PART_Track

Let's intentionally change the name of the PART_Track control.

<Track x:Name="PART_Track1" Grid.Row="1">
    ...
</Track>

Ensure you're in the correct Sliderhorizontal area.

Now, when you run the application, dragging the Track's Thumb will no longer move it left or right, as seen in the tutorial video. The reason the Thumb no longer moves is that the intentional name change prevents Code-behind from finding the PART_Track control through GetTemplateChild.

Since the PART_Track control cannot be found, there's no target for the mouse drag to move. Reverting the name to PART_Track1 will restore functionality.

This phenomenon can be observed in many other standard controls, notably the TextBox’s PART_ContentHost.

Test: Check the impact after intentionally changing the name of PART_SelectionRange

Next, let's intentionally change the name of the PART_SelectionRange control.

<Rectangle x:Name="PART_SelectionRange1" .../>

Ensure you're in the correct Sliderhorizontal area (x2).

And if you look at the trigger section, there are more parts using PART_SelectionRange, so this part should be changed as well.

<Trigger Property="IsSelectionRangeEnabled" Value="true">
    <Setter Property="Visibility" TargetName="PART_SelectionRange1" Value="Visible"/>
</Trigger>

Ensure you're in the correct Sliderhorizontal area (x3).

Also, in Slider, ensure all properties are set to activate the PART_SelectionRange.

<Slider Style="{DynamicResource SliderStyle1}"
        Minimum="0" Maximum="100"
        SelectionStart="0" SelectionEnd="50"
        IsSelectionRangeEnabled="True"/>

You need to set Minimum/Maximum, SelectionStart/SelectionEnd, and IsSelectionRange to activate the Range area.

  • Before name change: PART_SelectionRange

Before the change, you can see the Range area appearing normally.

  • After name change: PART_SelectionRange1

Now, the Range area no longer appears.

Similarly, because the PART_SelectionRange control cannot be internally found, there's no target for calculating the Range area.

Thus, WPF controls are implemented more loosely than expected while forming a modular structure. Taking advantage of these characteristics allows for efficient use of already implemented functionalities or excluding unnecessary ones.

7. Checking Code Behind (GitHub Open Source)

After a detailed look at the PART_ control naming rule and its impact, it's time to explore how these controls are utilized in actual classes.

The Code behind (class) area cannot be further examined through extraction. Therefore, it's necessary to review the Official source code through the WPF repository. For a more detailed examination, watching tutorial videos is recommended.

In the actual source code, the names of each PART_ control are agreed upon as strings like below:

private const string TrackName = "PART_Track";
private const string SelectionRangeElementName = "PART_SelectionRange";

The names are defined fixedly, emphasizing the importance of adhering to this naming rule.

WPF: OnApplyTemplate

Let's examine the part where Track and SelectionRange are retrieved from the (ControlTemplate) template.

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

    SelectionRangeElement = GetTemplateChild(SelectionRangeElementName) as FrameworkElement;
    Track = GetTemplateChild(TrackName) as Track;

    if (_autoToolTip != null)
    {
        _autoToolTip.PlacementTarget = Track != null ? Track.Thumb : null;
    }
}

The (Override) OnApplyTemplate method is called after the class and style are connected, making it the optimal time to use GetTemplateChild.

Upon reviewing the original source code, they are defined as FrameworkElement and Track, respectively.

  • PART_SelectionRange: SelectionRangeElement (FrameworkElement)
  • PART_Track: TrackName (Track)

It's noteworthy that while Track is the same type as in XAML, SelectionRange is defined as a FrameworkElement, different from the original Rectangle. This implies that the Range area can use any control, not just a Rectangle, indicating the type definition is intentionally flexible.

Therefore, it's reasonable to assume that (defined as a FrameworkElement type) SelectionRangeElement will handle only the basic functionalities available to this type.

Next, let's look at how the SelectionRangeElement is managed.

private void UpdateSelectionRangeElementPositionAndSize()
{
    Size trackSize = new Size(0d, 0d);
    Size thumbSize = new Size(0d, 0d);

    if (Track == null || DoubleUtil.LessThan(SelectionEnd,SelectionStart))
    {
        return;
    }

    trackSize = Track.RenderSize;
    thumbSize = (Track.Thumb != null) ? Track.Thumb.RenderSize : new Size(0d, 0d);

    double range = Maximum - Minimum;
    double valueToSize;

    FrameworkElement rangeElement = this.SelectionRangeElement as FrameworkElement;

    if (rangeElement == null)
    {
        return;
    }

    if (Orientation == Orientation.Horizontal)
    {
        // Calculate part size for HorizontalSlider
        if (DoubleUtil.AreClose(range, 0d) || (DoubleUtil.AreClose(trackSize.Width, thumbSize.Width)))
        {
            valueToSize = 0d;
        }
        else
        {
            valueToSize = Math.Max(0.0, (trackSize.Width - thumbSize.Width) / range);
        }

        rangeElement.Width = ((SelectionEnd - SelectionStart) * valueToSize);
        if (IsDirectionReversed)
        {
            Canvas.SetLeft(rangeElement, (thumbSize.Width * 0.5) + Math.Max(Maximum - SelectionEnd, 0) * valueToSize);
        }
        else
        {
            Canvas.SetLeft(rangeElement, (thumbSize.Width * 0.5) + Math.Max(SelectionStart - Minimum, 0) * valueToSize);
        }
    }
    else
    {
        // Calculate part size for VerticalSlider
        if (DoubleUtil.AreClose(range, 0d) || (DoubleUtil.AreClose(trackSize.Height, thumbSize.Height)))
        {
            valueToSize = 0d;
        }
        else
        {
            valueToSize = Math.Max(0.0, (trackSize.Height - thumbSize.Height) / range);
        }

        rangeElement.Height = ((SelectionEnd - SelectionStart) * valueToSize);
        if (IsDirectionReversed)
        {
            Canvas.SetTop(rangeElement, (thumbSize.Height * 0.5) + Math.Max(SelectionStart - Minimum, 0) * valueToSize);
        }
        else
        {
            Canvas.SetTop(rangeElement, (thumbSize.Height * 0.5) + Math.Max(Maximum - SelectionEnd,0) * valueToSize);
        }
    }
}

The logic for branching Orientation (Horizontal/Vertical) is essentially the same, so we only need to examine it based on Horizontal.

The (UpdateSelectionRangeElementPositionAndSize) method determines the size and position of the SelectionRange. Although the amount of source code might seem daunting, considering the duplicated source code for branching Orientation, it's easy to see that the handling of the SelectionRange is done succinctly.

This way, by extracting (CustomControl) controls and examining how PART_ controls are internally processed, it's possible to reverse-engineer and analyze them.

8. OnApplyTemplate in Cross-Platform

Cross-platforms, which retain many aspects of WPF's design, follow a similar flow. Let's take a look at how OnApplyTemplate is utilized in other platforms, based on our analysis.

List of platforms sharing the OnApplyTemplate design:

  • AvaloniaUI
  • Uno Platform
  • OpenSilver
  • MAUI
  • Xamarin
  • UWP
  • WinUI 3
  • Silverlight

Among these, let's examine the actual source code for AvaloniaUI, Uno Platform, OpenSilver, MAUI, and Xamarin, which are checked.

Note that except for Silverlight, all are managed through GitHub's official Dotnet or Xamarin Microsoft Organization, making it easy to find the repositories on GitHub.

AvaloniaUI: OnApplyTemplate

Below is a part of the Slider control's OnApplyTemplate in AvaloniaUI:

protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
    ...
    base.OnApplyTemplate(e);
    _decreaseButton = e.NameScope.Find<Button>("PART_DecreaseButton");
    _track = e.NameScope.Find<Track>("PART_Track");
    _increaseButton = e.NameScope.Find<Button>("PART_IncreaseButton");
    ...
}

AvaloniaUI, being open-source like WPF, allows for a detailed examination of all source code. It's also very similar to WPF in approach.

Through the naming rule, it's immediately clear that three PART_ controls operate as essential components within the XAML area. Shall we also take a look at Uno?

Uno Platform: OnApplyTemplate
protected override void OnApplyTemplate()
{
	...	
    base.OnApplyTemplate(e);
	
	// Get the parts
    var spElementHorizontalTemplateAsDO = GetTemplateChild("HorizontalTemplate");
    _tpElementHorizontalTemplate = spElementHorizontalTemplateAsDO as FrameworkElement;
    var spElementTopTickBarAsDO = GetTemplateChild("TopTickBar");
    ...
}

In Uno, it follows a similar approach to WPF.

However, it is somewhat surprising that Uno does not adhere to the PART_ naming convention. It seems that they have made a rule not to use such conventions from the beginning.

You can find similar source code in MAUI, OpenSilver, and Xamarin as well.

MAUI: OnApplyTemplate
protected override void OnApplyTemplate()
{
    base.OnApplyTemplate();
    _thumb = (Thumb)GetTemplateChild("HorizontalThumb");
    _originalThumbStyle = _thumb.Style;

    UpdateThumbStyle();
}

Unlike WPF, which declares variable names following the track, MAUI prefixes them with an underscore. Comparing the naming conventions and development patterns across different platforms is one of the small joys in analyzing open-source projects.

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

    // Get the parts
    ...
    ElementVerticalThumb = GetTemplateChild(ElementVerticalThumbName) as Thumb;
    ...
}

Uses a commenting style similar to Uno.

Xamarin: OnApplyTemplate
public override void OnApplyTemplate()
{
    base.OnApplyTemplate();
    FormsContentControl = Template.FindName("PART_Multi_Content", this) 
    	as FormsTransitioningContentControl;
}

Though there are slight differences, all share a design similar to WPF.

9. Concluding the Slider Analysis

We've taken a close look at the WPF Slider control, confirming that WPF (CustomControl) controls are intricately and well-designed. These principles apply equally to other controls and serve as a crucial foundation when designing new ones.

Some say WPF is dead. However, WPF is still very much alive and continues to hold its ground. Delving into WPF opens up endless possibilities and excitement.

If dreaming of developing everything with WPF was once just a fantasy, the advent of Xamarin and .NET Core, followed by various other platforms, has turned it into reality. This is the result of the wishes and contributions of many developers who love WPF.

We've looked in detail at why analyzing basic controls is essential. It is recommended to review the tutorial videos to reinforce and learn from the explanations.

Next, we will create a new Riot-style (CustomControl) Slider based on this analysis.

10. Creating a Riot-Style Slider (CustomControl) Control

Now, we will leverage the analysis of the Slider to minimally design and implement a control that captures its essence. The project's core is to complete the control without using any code by utilizing the PART_ sections.

Focus on understanding the content by closely following the implementation process and sequence. If you wish to deepen your understanding of CustomControl, it is recommended to study in depth through the book WPF Inside Out.

Motivation

It's unlikely that anyone would use the basic Slider as is. Needing inspiration, I chose to design a Slider based on the design concept seen in Riot Games' League of Legends, an experience I've had in creating such controls.

In fact, this design started a few years ago out of curiosity to implement a high-level game client in WPF for "League of Legends." If you're interested in seeing how this Slider control actually works, check out this repository. Furthermore, anyone can contribute through Fork, which has already seen over 80 forks.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • C# 100.0%