Bongo Paw Clicker Development Summary

This article is a development record of Bongo Paw Clicker, documenting the issues I encountered during the development of the auto clicker.

en-preview.webp

Links

UI Design

WPF employs the Extensible Application Markup Language (XAML) to generate UI elements. The XAML Designer in Visual Studio offers a visual interface to assist in UI design. Using XAML is somewhat akin to working with CSS, and it’s relatively easy to get started. The real challenge lies in the design aspect. On a personal note: I find that as a programmer, handling UI design can be truly exhausting – I often spend more time designing the UI than actually writing the code.

Window Layout

In my UI design, I utilized the Grid and StackPanel elements for layout purposes. The Grid is used to create horizontal functional areas, while the StackPanel is employed to arrange components horizontally within each row of the Grid. For more layout options, you can refer to the Microsoft documentation.

When placing multiple components directly within the same Grid, these components stack on top of each other instead of arranging in order. To achieve uniform spacing, one needs to assign Margins to each component individually, which can be quite cumbersome. That’s why I decided to nest a StackPanel within the Grid for component arrangement.

Grid

As the name implies, the Grid divides the panel into different grids.

Grid Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<Grid>
<Grid.RowDefinitions> <!-- Define rows -->
<RowDefinition Height="50" /> <!-- Define parameters for the first row, setting its height to 50 -->
<RowDefinition Height="60" /> <!-- Define parameters for the second row -->
</Grid.RowDefinitions>

<Grid Grid.Row="0"> <!-- First row -->
<Grid.ColumnDefinitions> <!-- Define columns -->
<ColumnDefinition Width="130" /> <!-- Define parameters for the first column, setting its width to 130 -->
<ColumnDefinition Width="*" /> <!-- Define parameters for the second column, using * to take up the remaining width in the row. If multiple columns have a width set to *, they will share the remaining width equally -->
</Grid.ColumnDefinitions>

<Grid Grid.Column="0"> <!-- Content of the first column -->
<!-- Some Content -->
</Grid>

<Grid Grid.Column="1"> <!-- Content of the second column -->
<!-- Some Content -->
</Grid>
</Grid>

<Grid Grid.Row="1"> <!-- Second row -->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" /> <!-- Here, 2* means it occupies 2/7 of the available width -->
<ColumnDefinition Width="5*" /> <!-- Here, 5* means it occupies 5/7 of the available width -->
</Grid.ColumnDefinitions>
</Grid>
</Grid>

StackPanel

StackPanel can arrange components horizontally or vertically in a single row. It does not automatically wrap content to the next line; any content exceeding the width will be hidden.

StackPanel Example

1
2
3
4
5
6
7
8
9
10
11
12
<StackPanel
Height="60" <!-- Set height -->
HorizontalAlignment="Left" //Set horizontal alignment
VerticalAlignment="Center" //Set vertical alignment
Orientation="Horizontal"> //Set orientation
<Label VerticalAlignment="Center">
<AccessText Text="1" />
</Label>
<Label VerticalAlignment="Center">
<AccessText Text="2" />
</Label>
</StackPanel>

Material Design

Initially, I thought about crafting my own UI styles, but as I delved deeper, I realized how naive I was. Dealing with things like rounded corner radii and button animations was quite overwhelming. So, I decided to take a different approach and use a UI framework directly in my program. The advantages of this approach are that it’s time-saving and visually appealing, but the downside is that the file size of the program after packaging might increase. After pondering for a while, I ultimately settled on Material Design In XAML.

Usage

In App.xaml, I added a new namespace reference: xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes", and then I added a few ResourceDictionary entries.

App.xaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<Application
x:Class="[PackageName].App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:[PackageName]"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignColors;component/Themes/Recommended/Primary/MaterialDesignColor.DeepPurple.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignColors;component/Themes/Recommended/Accent/MaterialDesignColor.Lime.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

Then, in each XAML file corresponding to a window, I added namespace references and related attributes (optional) within the Window tag.

Window Tag

1
2
3
4
5
6
7
8
9
10
<Window
// Existing content
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
TextElement.Foreground="{DynamicResource MaterialDesignBody}"
Background="{DynamicResource MaterialDesignPaper}"
TextElement.FontWeight="Medium"
TextElement.FontSize="14"
FontFamily="{materialDesign:MaterialDesignFont}"
// Other content>
</Window>

Window Style

Because I wanted to achieve a borderless window (I’m both inexperienced and adventurous🐶), I set the WindowStyle attribute to None within the Window tag. The advantage of doing this is the freedom it offers in designing the window style according to your preferences. However, the drawback is that this approach is overly flexible – functionalities like close, minimize, and others need to be implemented from scratch, and the native Windows animation effects are lost.

Function Buttons

First, in the MainWindow.xaml file, I redesigned the top bar. On the left is the application title, and on the right are the function buttons: Settings, Minimize, and Close. Then, I bound the Click events to the buttons.

Close Button Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!--MainWindow.xaml Example of Close Button-->
<Button
x:Name="CloseButton"
Width="30"
Height="30"
Margin="10"
VerticalAlignment="Center"
Click="CloseButton_Click" //Bind the close event
Style="{StaticResource MaterialDesignFloatingActionMiniButton}"
WindowChrome.IsHitTestVisibleInChrome="True">
<materialDesign:PackIcon
Width="25"
Height="25"
Kind="Close" />
</Button>

Finally, in MainWindow.xaml.cs, I re-implemented the button functionalities:

  • Close the window: Close()
  • Minimize: this.WindowState = WindowState.Minimized
  • Drag the window: DragMove()

Close Window Example

1
2
3
4
5
//MainWindow.xaml.cs Example of Close Window
private void CloseButton_Click(object sender, RoutedEventArgs e)
{
Close(); // Close the window
}

Window Shadow

To achieve the shadow effect, it’s necessary to allow window transparency and set the window background to a null value.

MainWindow.xaml Relevant Tags Example

1
2
3
4
5
6
<Window
// Other content
AllowsTransparency="True" //Allow transparency
Background="{x:Null}" //Set background to null
WindowStyle="None" //Disable the default Windows window style
// Other content>

Below are two incorrect examples. In the left image, window transparency wasn’t allowed, resulting in a black border around the window. In the right image, the background was set to red.

bpc_shadow2.png
bpc_shadow1.png

For the shadow effect, I used the DropShadowEffect within the Grid.

DropShadowEffect Example

1
2
3
4
5
6
7
<Grid.Effect>
<DropShadowEffect
BlurRadius="15" //The radius of the shadow blur effect, default value is 5
Opacity="0.8" //The higher the value, the more pronounced the shadow
ShadowDepth="0" //The distance of the shadow from the window; as this value increases, the shadow moves to the lower right of the window
Color="#666666" />
</Grid.Effect>

At this point, the shadow won’t be visible because it surrounds the Grid from the outside, and the current Grid already occupies the entire window space. Consequently, the shadow remains invisible. To fix this, we need to set an outer margin for the Grid using the Margin property. The specific margin value should be based on the width of the shadow.

Window Rounded Corners

There are multiple ways to achieve rounded corner effects. We can use UniformCornerRadius="radius" within a Card to uniformly set the radii for all four corners, or we can use CornerRadius="topLeft, topRight, bottomRight, bottomLeft" within a Border to set radii for each corner separately. I opted for the Card approach and set the corner radius to 15.

Window Rounded Corners Example

1
<materialDesign:Card Background="{DynamicResource CardBackgroundColor}" UniformCornerRadius="15"/>

Window Animation

There are four animations that need to be re-implemented: window startup, window close, minimize, and restore from minimize.

  • Window startup: Mimicking the native Windows animation.
  • Window close: Mimicking the native Windows animation.
  • Minimize: The window slides downward while gradually decreasing opacity.
  • Restore from minimize: The window slides upward from the bottom while gradually increasing opacity.

Create Animations

The native Windows startup animation involves the gradual increase of window opacity and the gradual expansion of the window from 0.7-0.8 times its size outward to its normal size. The window close animation is the reverse of this. I’ll illustrate how to create the animation for window startup. To achieve a similar effect, we need to create a Storyboard and add three DoubleAnimation instances to it for opacity change, horizontal stretch, and vertical stretch.

Opacity

Opacity animation is straightforward. The following code demonstrates how to animate the opacity from 0 to 1 when the window starts. Pay attention to the FillBehavior property; its default value is HoldEnd, meaning that the value of To in the animation will overwrite the program’s original value. This can cause issues in certain cases, so the solution is to set it to Stop.

Opacity Animation

1
2
3
4
5
6
7
<DoubleAnimation
FillBehavior="Stop"
Storyboard.TargetProperty="Opacity"
From="0" //Initial opacity
To="1" //Final opacity
Duration="0:0:0.2" //Animation duration: 0.2 seconds
/>
Window Stretch

First, set the x:Name and RenderTransformOrigin properties within the main Grid. x:Name is used for the stretch animation to find the corresponding Grid, while RenderTransformOrigin sets the starting point of the animation. Its values range from 0 to 1; when set to 0,0, the animation starts from the upper-left corner of the window, and when set to 1,1, it starts from the lower-right corner. Here, I set it to 0.5,0.5, which is the center of the window.

Next, add the RenderTransform property to the Grid. RenderTransform is a property used to apply transformations to an element during rendering. It allows us to apply transformations like translation, scaling, rotation, and skew to change an element’s position, size, and direction during rendering. To make it easier to add other effects later, I used the TransformGroup class, which is a container class in WPF used to combine multiple transformation effects. It allows us to combine translations, scaling, rotation, skew, and more, and apply them all simultaneously to an element.

The next step is to add the horizontal and vertical stretch animations, using DoubleAnimation, to the previously created Storyboard. Use Storyboard.TargetName to specify the object to be stretched and Storyboard.TargetProperty to specify the type of transformation.

Window Stretch Animation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<Window.Resources>
<ResourceDictionary>
<!--Storyboard that loads window animations-->
<Storyboard x:Key="ShowWindow">
<!--Opacity Animation-->
<DoubleAnimation
FillBehavior="Stop"
Storyboard.TargetProperty="Opacity"
From="0"
To="1"
Duration="0:0:0.2" />
<!--Horizontal Stretch-->
<DoubleAnimation
FillBehavior="Stop"
Storyboard.TargetName="MainGrid"
Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(ScaleX)"
From="0.8"
To="1"
Duration="0:0:0.2" />
<!--Vertical Stretch-->
<DoubleAnimation
FillBehavior="Stop"
Storyboard.TargetName="MainGrid"
Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(ScaleY)"
From="0.8"
To="1"
Duration="0:0:0.2" />
</Storyboard>
</ResourceDictionary>
</Window.Resources>

<!--Target Grid-->
<Grid
x:Name="MainGrid"
Margin="10"
RenderTransformOrigin="0.5,0.5"> <!--Starting point of window transformation-->

<Grid.RenderTransform>
<TransformGroup> <!--Container class-->
<ScaleTransform ScaleX="1" ScaleY="1" /> <!--Scale-->
<SkewTransform /> <!--Skew-->
<RotateTransform /> <!--Rotate-->
<TranslateTransform /> <!--Translate-->
</TransformGroup>
</Grid.RenderTransform>

<!--Other Contents-->
</Grid>

Trigger Animations

Triggering the window loading animation is quite simple; we just need to add a Window.Triggers section in the XAML file.

Window.Triggers Example

1
2
3
4
5
<Window.Triggers>
<EventTrigger RoutedEvent="Loaded"> <!-- Triggered when the window is loaded -->
<BeginStoryboard Storyboard="{StaticResource ShowWindow}" />
</EventTrigger>
</Window.Triggers>

However, animations like minimizing the window are not quite suitable for direct use with Window.Triggers, as there are some special cases where we need to determine whether to play the animation. Therefore, I triggered these animations in the code. Below is the code used to trigger the animation for restoring the window from minimized state. We need to add the line StateChanged="Window_StateChanged" to the Window tag in the XAML file to bind the window state change event.

Window State Change Code

1
2
3
4
5
6
7
8
9
10
11
private void Window_StateChanged(object sender, EventArgs e)
{
// Play the animation to restore the window from minimized state when the opacity is 0 and the size returns to normal.
// My minimize animation eventually sets the window opacity to 0, which makes it convenient to check here.
// If we don't check the window's opacity, the animation will also be triggered when the window regains focus after losing it without being minimized.
if (WindowState == WindowState.Normal && this.Opacity == 0)
{
var story = (Storyboard)this.Resources["MaximizeWindow"];
story.Begin(this);
}
}

Images and Audio

I created an “Asset” folder in the root directory to store media resources such as images and audio.

Application Icon

I designed a simple and straightforward icon – a Bongo Cat holding a mouse cursor. This was done using a few steps in Photoshop. To add the icon, open the project’s Properties, select Resources under the Application menu, then click the Browse button and locate your icon. Icon files must be in the ico format.
bpc_icon.png

Bongo Cat

I achieved the effect of Bongo Cat tapping the table in sync with mouse clicks by switching between four images representing different states of Bongo Cat’s paws:

  • Default state: Both paws are raised.
  • Left mouse button click: The left paw is tapped down.
  • Left mouse button double click: Both paws are simultaneously tapped down.
  • Right mouse button click: The right paw is tapped down.
nonePaw.png
leftPaw.png
rightPaw.png
bothPaw.png

First, I prepared the four images in Photoshop, aligning the outlines of Bongo Cat’s body and exporting them to files of the same size. I then placed them all within the same canvas and set them to the same size and alignment. The Visibility property of the three images, except for the default state image, was set to "Collapsed".

Visibility Property

Visibility property controls the visibility of an element on the interface. In WPF, the Visibility property can have three possible values: Visible, Collapsed, and Hidden.

  1. Visible: The element is visible and occupies layout space. It is displayed on the interface and responds to user interaction.
  2. Collapsed: The element is invisible and doesn’t occupy layout space. It won’t appear on the interface and won’t take up any space. Unlike Visible, when set to Collapsed, the element doesn’t leave any whitespace, and the surrounding layout becomes compact.
  3. Hidden: The element is invisible but still occupies layout space. Similar to Visible, the element won’t be visible, but it still occupies space, preventing the surrounding layout from compacting.

In general, if you want to completely hide an element and want the surrounding layout to rearrange to fill its place, you can use Collapsed. If you want the element to be hidden but still occupy space, you can use Hidden.

Next, I handled the interactions in the code. For instance, for a left mouse button single click, when simulating the click event, I set the left paw’s “Visible” property to true and collapsed the default state image. After a 100-millisecond delay, I restored the original state by collapsing the left paw and making the default state image visible again.

Left Mouse Click Example

1
2
3
4
5
6
7
8
9
10
11
12
if (clickType == 0) // Left mouse button click
{
Dispatcher.InvokeAsync((Action)(async () =>
{
LeftPaw.Visibility = Visibility.Visible;
NonePaw.Visibility = Visibility.Collapsed;
await Task.Delay(100);
LeftPaw.Visibility = Visibility.Collapsed;
NonePaw.Visibility = Visibility.Visible;
}));
User32API.ClickOnScreen(0, xCoord, yCoord); // Simulate a click
}

When a user interrupts the execution of the click event, the image switching might be interrupted, and the currently displayed image might not be the default state image. Therefore, after the simulation click command is interrupted or ends, we need to restore the images to their default state.

Play Audio

When adding audio, I encountered an interesting issue. If we use MediaPlayer directly in XAML, we need to use an absolute path to the audio file when calling it in the code. Using a relative path will result in a resource not found error. In the end, I added the audio file to Resources.resx and successfully called it using Properties.Resources.[FileName].

Audio Playback Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public partial class MainWindow : Window
{
// Audio player
System.Media.SoundPlayer meowAudio;

// Load audio when the window is loaded
private void Window_Loaded(object sender, RoutedEventArgs e)
{
meowAudio = new System.Media.SoundPlayer(Properties.Resources.meow);
}

// Play audio after simulating a click
private void ClickLuncher()
{
Dispatcher.InvokeAsync((Action)(() =>
{
meowAudio.Play();
}));
}
}

Here’s another tricky part: by default, Resources.resx shows the resource type as “String”. We need to switch it to “Audio” from the dropdown menu in the top-left corner. We also need to click a very small and subtle arrow to open the dropdown menu, as shown in the image below.

bpc_audio.png

UI Thread and Dispatcher

You might have noticed the use of Dispatcher for switching cat paw images in the code. This relates to the issue of the UI thread.

UI Thread

The UI thread is responsible for handling user interface interactions and updates. In WPF applications, the UI thread is responsible for creating, displaying, interacting with, and updating user interface elements such as windows, buttons, text boxes, and more. The UI thread is responsible for rendering the interface, responding to user input, and handling various UI events.

The importance of the UI thread lies in its thread context. Most UI frameworks, including WPF, require all UI-related operations to be performed on the same thread to ensure thread safety. This means you can’t directly update UI elements on a non-UI thread, as it might lead to cross-thread issues.

Some characteristics of the UI thread include:

  • Single-threaded model: In most UI frameworks, including WPF, the UI thread follows a single-threaded model. This means all UI element creation, modification, and interaction must be performed on the same UI thread.
  • User interface operations: The UI thread is responsible for handling various user interface operations, including responding to user input, processing mouse and keyboard events, handling window state changes (maximize, minimize, etc.).
  • Rendering and drawing: The UI thread is responsible for rendering and drawing user interface elements, ensuring they are correctly displayed on the screen.
  • Responsiveness: To maintain the responsiveness of an application, the UI thread should remain lightweight to promptly respond to user actions.
  • Thread safety: Operations on the UI thread must be thread-safe to avoid concurrency and synchronization issues.

When performing lengthy calculations or time-consuming operations, these tasks should be offloaded to non-UI threads to maintain UI smoothness. However, when updating UI elements, you still need to use the Dispatcher to dispatch operations to the UI thread to ensure thread safety.

Dispatcher

The Dispatcher is a mechanism in WPF that allows you to schedule operations on the UI thread from a non-UI thread. By using the Dispatcher, you ensure that UI element updates and operations occur on the correct thread, avoiding thread safety issues.

For instance, if you’re performing a calculation on a background thread and want to display the result on the UI, you need to use the Dispatcher to send the update operation to the UI thread. This ensures that UI element updates don’t lead to cross-thread problems.

The Dispatcher offers three commonly used methods: Invoke, BeginInvoke, and InvokeAsync.

  • Invoke is a synchronous method, which means the calling thread will be blocked until the operation is completed on the UI thread.
  • Both BeginInvoke and InvokeAsync are asynchronous methods. They enqueue the operation to the UI thread’s message queue and return immediately without blocking the calling thread, allowing it to continue executing subsequent code. InvokeAsync was introduced in .NET Framework 4.5. The main difference between the two is how they handle exceptions. InvokeAsync is preferred, especially when you need to use the await method.

Settings Panel

I created a separate window for the settings panel within the program. The settings panel provides the following functionalities:

  • Toggle between light and dark themes for the application.
  • Set whether the application should always stay on top.
  • Configure hotkeys.
  • Define reminders after completing clicks.

Create the Window

To begin, I added a button in the XAML file of the main window and bound it to the Click event Click="SettingButton_Click". Then, I added a new window to the project and named it SettingPanel. Next, I opened the code file of the main window and edited the automatically generated method code.
bpc_sp1.png

Creating the Settings Window

1
2
3
4
5
6
7
private void SettingButton_Click(object sender, RoutedEventArgs e)
{
SettingPanel settingPanel = new SettingPanel(); // Create the settings window
settingPanel.WindowStartupLocation = WindowStartupLocation.CenterOwner; // Set the window startup location to the center of the owner window
settingPanel.Owner = this; // Set the main window as the owner of the settings window
settingPanel.ShowDialog(); // Show the settings window
}

ShowDialog and Show

ShowDialog and Show

In WPF, Show and ShowDialog are two different ways to display windows.

Show

  • Windows displayed using the Show method are non-modal, meaning they do not prevent interaction with other parts of the application.
  • Users can freely switch between open windows, and even interact between the main window and other open windows.
  • The Show method returns immediately, without waiting for the opened window to close.

ShowDialog

  • Windows displayed using the ShowDialog method are modal, meaning they prevent interaction with other parts of the application until the window is closed.
  • Users must close the modal window before returning to the main window or other open windows.
  • The ShowDialog method returns after the opened window is closed, making it suitable for implementing dialogs that require user response.

Theme Switch

During late-night testing, I found the default light theme too harsh on the eyes. So, I decided to add a dark theme option with a button in the settings window for theme switching.

Material Design comes with built-in light and dark themes, as well as several sets of primary color schemes. The Primary Color further divides into Light, Normal, and Dark variants. Components like TextBox and ToggleButton use the Light variant, while Buttons use the Dark variant. I was dissatisfied with the Light variant’s color scheme, so I added a custom color scheme using resource dictionaries. I used my own color scheme for the window background, toggle buttons, etc., while using the default Material Design colors for regular buttons.

First, I added two SolidColorBrush properties in the App.xaml file. CardBackgroundColor represents the window’s background color, while IndicatorColor represents the theme color for various controls. By default, it is set to the light theme.

SolidColorBrush Example

1
2
3
4
5
6
7
<Application.Resources>
<ResourceDictionary>
<SolidColorBrush x:Key="CardBackgroundColor">#f8f8ff</SolidColorBrush>
<SolidColorBrush x:Key="IndicatorColor">#b39ddb</SolidColorBrush>
<!-- Other resources -->
</ResourceDictionary>
</Application.Resources>

Then, in the SettingPanel.xaml, I added a toggle button with Checked and Unchecked events bound to methods for turning on and off the dark mode.

Toggle Button Example

1
2
3
4
5
6
7
8
<ToggleButton
x:Name="DarkModeToggleButton"
Margin="10,0,0,0"
Background="{DynamicResource IndicatorColor}"
Checked="DarkModeToggleButton_Checked"
Effect="{StaticResource MaterialDesignElevationShadow2}"
Style="{StaticResource MaterialDesignSwitchLightToggleButton}"
Unchecked="DarkModeToggleButton_Unchecked" />

Next, I edited the corresponding methods in SettingPanel.xaml.cs. Initially, I placed all the theme-switching code in the settings window. However, I noticed that clicking the toggle button only changed the settings window’s theme, and the main window only switched to the default Material Design dark theme. My custom colors scheme didn’t change. I realized I needed to force a refresh of Dynamic Resources in the main window to change the custom colors. So for the sake of convenience, I just moved the theme-switching code to the main window.

Code in Setting Window

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//SettingPanel.xaml.cs
//Force the current window to refresh Dynamic Resources

//Switch to dark theme
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs e)
{
if (Owner is MainWindow mainWindow)
{
mainWindow.SwitchToDarkMode();
}
//Force the current window to refresh Dynamic Resources
Resources["CardBackgroundColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#3d3d3d"));
Resources["IndicatorColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#ffb330"));
}

//Switch to light theme
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs e)
{
if (Owner is MainWindow mainWindow)
{
mainWindow.SwitchToLightMode();
}
//Force the current window to refresh Dynamic Resources
Resources["CardBackgroundColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#f8f8ff"));
Resources["IndicatorColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#b39ddb"));
}

Code in Main Window

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//MainWindow.xaml.cs
//Main code for theme switching

//Switch to dark mode
public void SwitchToDarkMode()
{
var paletteHelper = new PaletteHelper();
//Get the primary color Amber from the built-in theme provided by Material Design
PrimaryColor primary = PrimaryColor.Amber;
Color primaryColor = SwatchHelper.Lookup[(MaterialDesignColor)primary];

//Switch to a custom color scheme: Dark gray for background and gold for components
Resources["CardBackgroundColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#3d3d3d"));
Resources["IndicatorColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#ffb330"));

//Switch to the built-in dark theme provided by Material Design
ITheme theme = paletteHelper.GetTheme();
theme.SetBaseTheme(Theme.Dark);
theme.SetPrimaryColor(primaryColor);
paletteHelper.SetTheme(theme);
}

//Switch to light mode
public void SwitchToLightMode()
{
var paletteHelper = new PaletteHelper();
//Get the primary color DeepPurple from the built-in theme provided by Material Design
PrimaryColor primary = PrimaryColor.DeepPurple;
Color primaryColor = SwatchHelper.Lookup[(MaterialDesignColor)primary];

//Switch to a custom color scheme: Light gray for background and light purple for components
Resources["CardBackgroundColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#f8f8ff"));
Resources["IndicatorColor"] = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#b39ddb"));

//Switch to the built-in light theme provided by Material Design
ITheme theme = paletteHelper.GetTheme();
theme.SetBaseTheme(Theme.Light);
theme.SetPrimaryColor(primaryColor);
paletteHelper.SetTheme(theme);
}

Finally, set the Background="{DynamicResource IndicatorColor}" or BorderBrush="{DynamicResource IndicatorColor}" attribute to the components that require the application of the custom colour scheme, instead of using Material Design’s Primary Color.

Simulate Mouse Clicks

Simulating mouse clicks involves using the user32.dll. user32.dll is a core DLL in the Windows operating system and contains many functions related to user interface, such as creating windows, displaying message boxes, handling input, etc. To call user32.dll in a .NET program, you need to use P/Invoke(Platform Invocation Services) with the [DllImport("user32.dll")] attribute, and the method signature includes the extern keyword to indicate it’s an external method.

I created a class named User32API.cs to store code related to user32.dll. Simulating a click involves two steps: first, using the SetCursorPos function to move the cursor to the specified screen coordinates, and second, using the mouse_event function to perform the actual click based on the specified click type.

SetCursorPos

The SetCursorPos function moves the mouse cursor to the specified screen coordinates. The function prototype is as follows:

1
BOOL SetCursorPos(int X, int Y);

Parameter explanation:

  • X: The X-coordinate value in screen pixel units, representing the horizontal position.
  • Y: The Y-coordinate value in screen pixel units, representing the vertical position.

After calling this function, the mouse cursor will be moved to the specified coordinates, regardless of whether the mouse is inside an active window. Additionally, this function doesn’t show the movement visually.

If the provided X or Y values are outside the screen boundaries, the system limits the mouse position to the visible area of the screen. For example, if you set X to a negative value, the system will set it to the left boundary, and if you set X to a value greater than the screen width, the system will set it to the right boundary. The same logic applies to the Y value.

SetCursorPos Example

1
2
[DllImport("user32.dll")]
private static extern int SetCursorPos(int x, int y);

mouse_event

The mouse_event function simulates mouse operations such as clicks, movements, scrolling, etc. The function prototype is as follows:

1
2
3
4
5
6
7
VOID mouse_event(
DWORD dwFlags,
DWORD dx,
DWORD dy,
DWORD dwData,
ULONG_PTR dwExtraInfo
);

Parameter explanation:

  • dwFlags: Flags representing the type of mouse operation, indicating the action type (e.g., click, move, scroll, etc.).
    • Common Action Types

      TypeValueDescription
      MOUSEEVENTF_ABSOLUTE0x8000Absolute coordinate flag
      MOUSEEVENTF_LEFTDOWN0x0002Left button down
      MOUSEEVENTF_LEFTUP0x0004Left button up
      MOUSEEVENTF_RIGHTDOWN0x0008Right button down
      MOUSEEVENTF_RIGHTUP0x0010Right button up
  • There are two uses for dx and dy
    • If you use the MOUSEEVENTF_ABSOLUTE flag, dx and dy are interpreted as absolute screen coordinates. In this case, their values typically range from 0 to 65535. This is because the screen coordinate system usually has a range of 0 to 65535, where (0, 0) represents the top-left corner of the screen, and (65535, 65535) represents the bottom-right corner. If you want to simulate absolute mouse movement on the screen, you need to use absolute coordinate values.
      • Example: Move the mouse to the center of the screen

        1
        2
        3
        4
        5
        int screenWidth = 1920;
        int screenHeight = 1080;
        int centerX = (screenWidth * 65535) / 2;
        int centerY = (screenHeight * 65535) / 2;
        mouse_event(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE, centerX, centerY, 0, 0);
    • If you don’t use the MOUSEEVENTF_ABSOLUTE flag, dx and dy are interpreted as relative movement in pixels. In this case, their values usually range from a signed 16-bit integer, where negative values indicate left or upward movement, and positive values indicate right or downward movement.
  • dwData is used to indicate the scroll distance for mouse scrolling. A positive value means scrolling up, while a negative value means scrolling down. If the dwFlags does not include a mouse scroll event, set this to 0.
  • dwExtraInfo represents additional information, usually set to 0.

Simulating Clicks Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private const int MOUSEEVENT_LEFTDOWN = 0x0002;
private const int MOUSEEVENTF_LEFTUP = 0x0004;
private const int MOUSEEVENTF_RIGHTDOWN = 0x0008;
private const int MOUSEEVENTF_RIGHTUP = 0x0010;
private const int MOUSEEVENTF_ABSOLUTE = 0x8000;

// Simulate a right-click
mouse_event(MOUSEEVENTF_RIGHTDOWN | MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0);

// Simulate a left-click
mouse_event(MOUSEEVENT_LEFTDOWN | MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);

// Simulate a left double-click (two consecutive left-clicks)
mouse_event(MOUSEEVENT_LEFTDOWN | MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
mouse_event(MOUSEEVENT_LEFTDOWN | MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);

Capturing Mouse Coordinates

Directly asking users to input coordinates for clicking is not practical because there’s no pixel ruler on the screen to indicate cursor position. Therefore, we need to design a window that displays cursor coordinates in real-time and captures cursor pixel coordinates when the user presses the left mouse button. Additionally, since we’ve added support for random clicking within a specified area, this window should also allow capturing pixel coordinates through rectangle selection.

Create New Window

  1. Create a new window.
  2. Set the window state to maximise and always on top, and remove the default window style.
  3. Allow window transparency and set the opacity to 0.5 so that users can see the content behind the window.
  4. Bind the KeyDown event to close the window when the user presses the Esc key.
  5. Bind the MouseMove event to update cursor pixel coordinates in real-time.
  6. Bind the MouseLeftButtonDown and MouseLeftButtonUp events for point and rectangle selection.
  7. Add two Label elements to display pixel coordinates as the cursor moves.
  8. Add a Rectangle element for previewing the selected rectangle.

Window Tag Example for Capturing Mouse Coordinates

1
2
3
4
5
6
7
8
9
10
11
<Window
AllowsTransparency="True"
Background="Gray"
KeyDown="PositionSelector_KeyDown"
MouseLeftButtonDown="PositionSelectorCanvas_MouseLeftButtonDown"
MouseLeftButtonUp="PositionSelectorCanvas_MouseLeftButtonUp"
MouseMove="PositionSelectorCanvas_MouseMove"
Opacity="0.5"
Topmost="True"
WindowState="Maximized"
WindowStyle="None">

Get Cursor Coordinates

We can use e.GetPosition(this) to get the coordinates. GetPosition is a method in WPF used to get the local coordinates of the mouse or touch operation relative to a specified element. It returns a Point object, which is a structure representing a 2D coordinate point. It has two properties: X and Y, representing the horizontal and vertical coordinates, respectively. Both Point.X and Point.Y are of type double.

Note that the coordinates obtained here are local coordinates of the window, not absolute screen pixel coordinates. We need to convert them to screen coordinates using the PointToScreen method. The PointToScreen method takes a Point object representing the coordinate relative to the calling element. It returns a Point object representing the absolute position on the screen.

Rectangle Preview and Capture

The preview rectangle needs to update in real-time as the cursor moves. We need to adjust the rectangle’s width and height each time the MouseMove event is triggered. Since the preview box is drawn inside the window, the window local coordinates are used directly here. Additionally, we need to consider the possibility of reverse selection, where the user drags the selection from the bottom-right corner to the top-left corner. Therefore, the length and width of the rectangle needs to be taken as the absolute value of the difference between the coordinates of the two points.

If the user has enabled clicking within a random range, the program needs to capture two screen coordinates: the starting and ending points of the selection rectangle, i.e., the points where the left mouse button was pressed and released. I used variables w and h to represent the width and height of the rectangle, and their values are the absolute differences between the horizontal and vertical coordinates of the two points.

Code for Rectangle Preview and Capture

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
//Screen Pixel Coordinates
private Point mouseDownPoint;
private Point mouseUpPoint;
private Point currentPoint;

private bool randomAreaSelection = false;//Enable random clicks
private bool isDragging = false;

//Range Parameters
private int x = 0;
private int y = 0;
private int w = 0;
private int h = 0;

//Local Window Coordinates
private Point previewRectangleStartPoint;
private Point previewRectangleEndPoint;

//Left Mouse Button Down
private void PositionSelectorCanvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
//Starting point of the selection rectangle
mouseDownPoint = PointToScreen(e.GetPosition(this));
x = (int)mouseDownPoint.X;
y = (int)mouseDownPoint.Y;

if (randomAreaSelection)
{
//Starting point of the selection preview rectangle
previewRectangleStartPoint = e.GetPosition(this);
isDragging = true;
//Show preview rectangle
SelectedAreaPreview.Visibility = Visibility.Visible;
}
}

//Left mouse button up
private void PositionSelectorCanvas_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (randomAreaSelection)
{
//Ending point of the selection rectangle
mouseUpPoint = PointToScreen(e.GetPosition(this));
w = (int)mouseUpPoint.X - x;
h = (int)mouseUpPoint.Y - y;

//If the user makes a reverse selection, the relevant parameters need to be transformed
if (w < 0)
{
x += w;
w *= -1;
}
if (h < 0)
{
y += h;
h *= -1;
}
isDragging = false;
//Hide preview rectangle
SelectedAreaPreview.Visibility = Visibility.Hidden;
}
}

//Mouse Move
private void PositionSelectorCanvas_MouseMove(object sender, MouseEventArgs e)
{
//Get the real-time screen coordinates of the cursor and update the contents of the Label.
currentPoint = PointToScreen(e.GetPosition(this));
XCoordsPreview.Content = (int)currentPoint.X;
YCoordsPreview.Content = (int)currentPoint.Y;

//Preview rectangle drag points
previewRectangleEndPoint = e.GetPosition(this);
if (randomAreaSelection && isDragging)
{
//Update the preview rectangle
SelectedAreaPreview.Margin = new Thickness(
Math.Min((int)previewRectangleStartPoint.X, (int)previewRectangleEndPoint.X),
Math.Min((int)previewRectangleStartPoint.Y, (int)previewRectangleEndPoint.Y),
0, 0);
SelectedAreaPreview.Width = Math.Abs((int)previewRectangleEndPoint.X - (int)previewRectangleStartPoint.X);
SelectedAreaPreview.Height = Math.Abs((int)previewRectangleEndPoint.Y - (int)previewRectangleStartPoint.Y);
}
}

Store User Configuration

To store user-customized configurations, we need to set the program automatically load and restore these configurations when the program starts, and save them when the program closes, follow these steps:

  1. Open ./Properties/Settings.settings.
  2. Add the properties you want to save, set their initial values and types.
  3. In the main window’s XAML, bind the Loaded event to the Window_Loaded event handler.
  4. In the code, use Properties.Settings.Default.[PropertyName] to read the corresponding properties.
  5. Before the window is closed, use Properties.Settings.Default.Save(); to save the properties.

Hotkey Configuration

If the user sets a relatively small interval for clicks, they won’t be able to prematurely end the simulated clicks by clicking the stop button. This is because the program will keep moving the cursor to the predefined points. Therefore, it is necessary to implement the functionality of stopping the clicks using hotkeys. Additionally, since the hotkeys of this program might conflict with the hotkeys of other software the user is using, it’s important to provide the ability for users to customize the hotkeys.

Configuring Hotkeys

UI

I have added a text box in the settings window to record the key pressed by the user, followed by an “OK” button to trigger subsequent actions.

The text box needs to be set as read-only and bound to the PreviewKeyDown event. Unlike the regular KeyDown event, the PreviewKeyDown event is a tunneling event that travels down the visual tree from the parent element and then bubbles back up from the target element. The purpose of this event is to allow preprocessing or interception of keyboard events after a key is pressed but before it is released.

Text Box Example

1
2
3
4
5
6
7
<TextBox
x:Name="HotKeyTextBox"
VerticalContentAlignment="Center"
IsReadOnly="true"
PreviewKeyDown="HotKeyTextBox_PreviewKeyDown"
TextWrapping="NoWrap"
VerticalScrollBarVisibility="Hidden" />

Preprocess Keyboard Events

When the PreviewKeyDown event is triggered, the program will determine whether the key pressed by the user is valid, as there are some special keys that cannot be used as hotkeys and need to be excluded. After processing, the pressed key will be returned as a string and displayed in the text box.

The keys pressed by the user can be classified into two categories: Key and ModifierKeys. These are both enumerations used for handling keyboard input.

  • Key: The Key enumeration defines all possible keyboard keys, including letters, numbers, function keys, control keys, and more. This enumeration allows determining which key the user has pressed. Here are some examples of the Key enumeration:
    • Key.A: Represents the letter A key
    • Key.Escape: Represents the Esc key
    • Key.Enter: Represents the Enter key
    • Key.Space: Represents the space key
    • For a complete list of enumerations, refer to the Microsoft official documentation
  • ModifierKeys: The ModifierKeys enumeration defines a set of modifier keys that are typically used in combination with other keys to perform specific actions. These modifier keys include Shift, Ctrl, Alt, and the Windows key. Here are some examples of the ModifierKeys enumeration:
    • ModifierKeys.None: No modifier keys are pressed
    • ModifierKeys.Shift: The Shift key is pressed
    • ModifierKeys.Control: The Ctrl key is pressed
    • ModifierKeys.Alt: The Alt key is pressed

Because some keys in the Key enumeration are the same as the modifier keys in the ModifierKeys enumeration, these keys need to be excluded.

Keyboard Event Preprocessing Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
private Key hotKey;
private ModifierKeys hotKeyModifiers;

//Keyboard preprocessing events
private void HotKeyTextBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
try
{
//Determine whether a Key is valid or not
if (IsForbiddenKey(e.Key))
{
// If the key is not valid then the event is prevented from propagating further
e.Handled = true;
return;
}
//Assigns user-pressed keys to variables
hotKey = e.Key;
hotKeyModifiers = Keyboard.Modifiers;
UpdateHotKeyText();
}
catch (Exception ex)
{
MessageBox.Show(ex.ToString(), "Error!", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
}

//Update text box
private void UpdateHotKeyText()
{
HotKeyTextBox.Text = $"{hotKeyModifiers} + {hotKey}";
}

private bool IsForbiddenKey(Key key)
{
return Enum.IsDefined(typeof(ForbiddenKeys), key.ToString());
}

//Invalid Key Enumeration
public enum ForbiddenKeys
{
Back,
Capital, CapsLock,
Delete, Down,
End, Enter, Escape,
Home,
Insert,
Left, LeftAlt, LeftCtrl, LeftShift, LWin,
Next, NumLock,
PageDown, PageUp, Pause, PrintScreen,
Return, Right, RightAlt, RightCtrl, RightShift, RWin,
System,
Tab,
Up,
VolumeDown, VolumeMute, VolumeUp
};

Register/Unregister Global Hotkeys

The registration and unregistration of global hotkeys also involve the use of user32.dll. The function used for registration is RegisterHotKey, and for unregistration, it’s UnregisterHotKey. It is recommended to register global hotkeys when the program starts and unregister them upon exit to avoid conflicts with hotkeys from other programs.

RegisterHotKey Function

Purpose: Registers a global hotkey in the operating system that can be defined by the user and can trigger even when the application is running in the background or without focus.

1
2
3
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool RegisterHotKey(IntPtr hWnd, int id, ModifierKeys fsModifiers, uint vk);

Parameters:

  • hWnd: The handle of the window that will receive the hotkey notification, typically the handle of the main window.
  • id: Identifier for the hotkey, used to distinguish different hotkeys.
  • fsModifiers: Hotkey modifiers such as Ctrl, Alt, Shift, etc.
  • vk: Virtual key code to be used with the hotkey, can be obtained through conversion from the Key enumeration.

By default, the function returns a non-zero value if the hotkey is successfully registered and zero on failure. However, for ease of further processing, the MarshalAs attribute is used here to convert the return value into a boolean type.

UnregisterHotKey Function

Purpose: Unregisters a previously registered global hotkey.

1
2
[DllImport("user32.dll")]
static extern bool UnregisterHotKey(IntPtr hWnd, int id);

Parameters:

  • hWnd: The window handle provided during the previous hotkey registration.
  • id: The hotkey identifier provided during the previous registration.

Binding Hotkey Functionality

Create a new method called Regist in User32API.cs to bind the hotkey functionality. When the program starts, the main window reads Key and ModifierKeys from user configuration, and then passes them along with a callback function to bind the hotkey command.

Main Window Code

1
2
3
4
5
6
7
8
9
10
11
12
// Override the window initialization method
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);

// Pass relevant parameters to the Regist method in User32API.cs
Regist(this, hotKeyModifiers, hotKey, () =>
{
if (StopButton.IsEnabled) { StopButton.RaiseEvent(new RoutedEventArgs(System.Windows.Controls.Primitives.ButtonBase.ClickEvent)); }
else { StartButton.RaiseEvent(new RoutedEventArgs(System.Windows.Controls.Primitives.ButtonBase.ClickEvent)); }
});
}

In addition, I have defined a delegate type HotKeyCallBackHanlder here. It’s a pointer to a function and is used to define the action to be taken when a global hotkey is triggered.

A delegate is a reference type variable that holds a reference to a method with a particular signature. It allows us to define the signature of a method and then use it as a parameter for methods or assign it to other method variables for dynamic method invocation at runtime.

Hotkey Registration Code and Explanation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public delegate void HotKeyCallBackHanlder();
public static HotKeyCallBackHanlder hotKeyCallBackHanlder = null;

public static void Regist(Window window, ModifierKeys fsModifiers, Key key, HotKeyCallBackHanlder callBack)
{
// Get the window's handle
var hwnd = new WindowInteropHelper(window).Handle;
// Create an HwndSource object using the window's handle
var _hwndSource = HwndSource.FromHwnd(hwnd);
// Add the message processing delegate WndProc to the window's message processing chain,
// so that when the window receives a message, it's first passed to the WndProc method for handling
_hwndSource.AddHook(WndProc);

// Convert the incoming Key to a virtual key code (vk)
var vk = KeyInterop.VirtualKeyFromKey(key);

// Display an error message box if registration fails
if (!RegisterHotKey(hwnd, HOTKEY_ID, fsModifiers, (uint)vk))
MessageBox.Show("Failed to register global hotkey!");

// Assign the callback function to the hotKeyCallBackHanlder variable.
// This callback function is the 'callBack' parameter passed in from the main window during registration
hotKeyCallBackHanlder = callBack;
}

Handle Hotkey Messages

In Windows, WndProc is a function used to handle window messages. When a registered hotkey is triggered, the system sends a window message to the specified window handle, and we can process this message in the WndProc function.

Handling Window Messages in WndProc

1
2
3
4
5
6
7
8
9
10
static IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
// When receiving the WM_HOTKEY message and the hotkey ID matches, the hotKeyCallBackHanlder() is called,
// which executes the pre-defined callback function.
if (msg == WM_HOTKEY && wParam.ToInt32() == HOTKEY_ID)
{
hotKeyCallBackHanlder();
}
return IntPtr.Zero;
}

Change Hotkeys

Create a new method called ModifyGlobalHotKey in User32API.cs. When the user enters a new hotkey in the settings window and clicks the “OK” button, the program will update the variable storing the hotkey value and pass the updated hotkey value to User32API.cs.

ModifyGlobalHotKey will first unregister the previous hotkey and then attempt to register the new hotkey. If the registration fails, an error message box will be displayed.

Modifying Hotkeys with ModifyGlobalHotKey

1
2
3
4
5
6
7
8
public static void ModifyGlobalHotKey(WindowInteropHelper windowInteropHelper, Key key, ModifierKeys modifiers)
{
// Unregister the previous hotkey
UnregisterHotKey(windowInteropHelper.Handle, HOTKEY_ID);
// Attempt to register the new hotkey
if (!RegisterHotKey(windowInteropHelper.Handle, HOTKEY_ID, modifiers, (uint)KeyInterop.VirtualKeyFromKey(key)))
MessageBox.Show("Failed to register global hotkey!");
}

Read-in and Save Hotkeys

Since both the Key and ModifierKeys that make up a hotkey are enumeration types, and ./Properties/Settings.settings does not support storing enumeration types, we need to use ToString() to convert them into string types before saving.

Save Hotkeys Code

1
2
3
4
5
6
7
8
9
10
private Key hotKey;
private ModifierKeys hotKeyModifiers;

private void CloseButton_Click(object sender, RoutedEventArgs e)
{
Properties.Settings.Default.HotKey = hotKey.ToString();
Properties.Settings.Default.Modifiers = hotKeyModifiers.ToString();
Properties.Settings.Default.Save();
//Close();
}

During program startup, use Enum.Parse() to convert the stored strings back into enumeration types. Make sure to place this code in the window’s initialization code; if placed in the window’s load code, this segment might not get executed, which is confusing.

Read-in Hotkeys Code

1
2
3
4
5
6
7
8
9
10
11
private Key hotKey;
private ModifierKeys hotKeyModifiers;

public MainWindow()
{
InitializeComponent();

// Read-in and convert hotkeys
hotKey = (Key)Enum.Parse(typeof(Key), Properties.Settings.Default.HotKey);
hotKeyModifiers = (ModifierKeys)Enum.Parse(typeof(ModifierKeys), Properties.Settings.Default.Modifiers);
}

Localization

There are multiple methods for localizing WPF applications, such as using resource dictionaries, NuGet packages, and .resx resource files. I’m using the relatively convenient .resx resource files.

Create Resource Files

  1. Open ./Properties/Resources.resx in the Visual Studio Solution Explorer. The structure here is similar to a Dictionary<string, string>, where the name serves as the Key and the value is the Value.
  2. Fill in default language resource key-value pairs.
  3. Change the Access Modifier from Internal to Public.
  4. Add a new resource file and name it as Resources.[LCID].resx, where [LCID] is the Windows Language Code Identifier. For example, the resource file for Simplified Chinese should be named Resources.zh-CN.resx. For a complete list of language codes, refer to MS-LCID.
    bpc_resx1.png
    bpc_resx2.png
  5. Copy the contents from Resources.resx to the newly created resource file and modify the values according to the language.
    bpc_resx3.png

Usage

XAML

In your XAML files, add a new namespace reference, replacing PackageName with your project name:

1
xmlns:lang="clr-namespace:[PackageName].Properties"

Use x:Static to reference resources in your text content. In the following example, KeyName is the key value from the language resource file:

1
Text="{x:Static lang:Resources.KeyName}"

Code

In your code, you can directly reference resources:

1
var example = Properties.Resources.KeyName;

Runtime

The functionality I implemented is that the program automatically switches to the corresponding language based on the system’s language identifier when it starts. Currently, only Simplified Chinese and English are supported. You can use CultureInfo.CurrentUICulture.Name to get the system’s language identifier. To achieve this, you need to modify App.xaml.cs. Here’s the complete code:

1
2
3
4
5
6
7
8
9
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
if (CultureInfo.CurrentUICulture.Name == "zh-CN") // If the current system language is zh-CN (Simplified Chinese)
{
Thread.CurrentThread.CurrentUICulture = new CultureInfo("zh-CN"); // Set the program language to Simplified Chinese using ./Properties/Resources.zh-CN.resx
}
// Otherwise, use the default language set in ./Properties/Resources.resx
}

Packaging the Program

The solution folder generated by Visual Studio contains many files, including resource files such as images and audio, as well as the necessary DLL runtime libraries. These files are essential for the program to run and are often referred to as dependencies. If you try to copy only the executable file (exe) to another directory, it won’t run because it lacks these dependencies. This poses the problem that users need to download the entire folder containing dependencies to use the software, which is inconvenient. Therefore, the challenge here is to package all the required files into a single executable (exe) so that users can use it directly. The solution involves installing two NuGet packages: Costura.Fody and Resource.Embedder.

Costura.Fody

Costura.Fody is an open-source add-in for Fody. Its main function is to embed dependencies from assemblies into the main assembly, reducing the need for external dependencies during deployment and distribution. It achieves this by modifying the Intermediate Language (IL) code during compilation, so you don’t have to worry about missing external dependencies at runtime.

Installation and Usage

Search for Costura.Fody in the NuGet Manager in Visual Studio and install it. Since it’s an add-in for Fody, installing it will also install Fody itself along with many dependencies, around 40 of them.

After installation, create a new file named FodyWeavers.xml and add it to the root directory of your solution explorer. Then, paste the following content into the file and save it:

1
2
3
4
<?xml version="1.0" encoding="utf-8"?>
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Costura />
</Weavers>

Now, rebuild the solution in Visual Studio. You’ll notice that the size of the generated executable (exe) file has increased significantly. When you copy this executable to another directory, it can be used normally without needing to copy additional files.

New Issue

While adding localization support to the program, I encountered a new problem. The newly created .resx language files were not being correctly added to the executable (exe) by Costura.Fody, resulting in the program always displaying in English during runtime. If I was debugging within Visual Studio, the program could display in the newly added language, but when I copied the exe file separately, it reverted to the default language.

At this point, the file structure in the ./bin/Debug directory looked like this:

1
2
3
4
5
6
7
8
./Debug

├──ProjectName.exe

└──./zh-CN

└──xxxxx.dll

Based on my research, I deduced that this issue might be related to the fact that the localized resources are part of Satellite Assemblies. Resources from satellite assemblies are available in specific cultures, and these resources are loaded only when the user selects the corresponding culture settings. However, during the execution of Costura.Fody, the new language files weren’t selected and loaded. Consequently, only the default language files were embedded, and the new language files were loaded into the solution directory after the packaging was completed.

The related issue has already been discussed on GitHub: Costura doesn’t embed resource satellite assemblies, and a solution has been provided: Install Resource.Embedder and use it in conjunction with Costura.Fody.

Satellite Assemblies

Satellite Assemblies

In WPF, primary assemblies and satellite assemblies are two different concepts of assemblies that have some differences in how they organize, deploy, and access resources in an application.

Primary Assembly

The primary assembly is the core assembly of an application. It usually contains the application’s startup code, main logic, and main window. The primary assembly can include various resources such as XAML files, images, styles, templates, etc. These resources can be accessed and used throughout the application’s lifecycle. The primary assembly typically has the following characteristics:

  • Entry Point: The primary assembly contains the entry point of the application, which is the code executed when the application starts.
  • Global Resources: Resources in the primary assembly can be accessed throughout the application without specific references.
  • In-Memory: Resources in the primary assembly remain in memory during the application’s runtime, unless explicitly released.

Satellite Assembly

Satellite assemblies are auxiliary assemblies used for localization and resource management. They contain translated or localized versions of an application’s localized text, images, and other resources. Satellite assemblies enable the application to choose the correct resources based on the user’s language and region. Satellite assemblies generally have the following characteristics:

  • Localized Resources: Satellite assemblies include localized or different language versions of resources to accommodate various languages and regions.
  • Lazy Loading: Satellite assemblies are loaded into memory only when needed to reduce memory consumption.
  • Naming Convention: Satellite assemblies are usually named based on the language and region, such as AssemblyName.resources.dll.
  • Region-Specific: Resources in satellite assemblies are available in specific regions, and these resources are loaded only when the user selects the corresponding regional settings.

Summary

The primary assembly contains the main logic and resources of the application, while satellite assemblies are used for localization and resource management. The primary assembly includes the core code and global resources, while satellite assemblies contain localized resources specific to languages and regions. By separating resources into primary and satellite assemblies, WPF applications can achieve multilingual support, resource management, and better memory utilization.

Important Note

After rebuilding the solution using Costura.Fody, you might encounter errors in the Visual Studio XAML file preview. This is because Costura.Fody embeds all resources into the exe file, causing the Visual Studio design environment to be unable to find resources based on their original relative paths, resulting in preview errors. To resolve this, you can temporarily comment out the <Costura /> tag in the FodyWeavers.xml file when designing and previewing.

Resource.Embedder

Resource.Embedder is a tool for embedding resource files into .NET assemblies. Its working principle involves creating a virtual file system, embedding resource files into it, and then having Costura.Fody embed this virtual file system into the main assembly, achieving the embedding of satellite assemblies.

The installation process is similar to the previous package. Search for it in the NuGet Manager in Visual Studio and install it. After installation, there is no need to perform any additional configurations. Rebuild the solution in Visual Studio, and now the exe file should display in the new language and be runnable as a standalone application.

.NET Framework

The .NET Framework is a development framework developed by Microsoft for building and running applications on the Windows operating system. The program targets the .NET Framework 4.8 as its framework version.

Starting from the Windows 10 May 2019 Update (version 1903), .NET Framework 4.8 began to be pre-installed on Windows systems. Therefore, if you are running this program on an earlier version of Windows, you need to install .NET Framework 4.8 separately.

Relationship Between .NET Framework Versions and Windows Versions

  • .NET Framework 1.0 and 1.1: Released in compatibility with Windows XP.
  • .NET Framework 2.0: Compatible with Windows XP, Windows Server 2003, and Windows Vista.
  • .NET Framework 3.0 and 3.5: Built into Windows Vista and Windows Server 2008, and can also be installed on Windows XP and Windows Server 2003 through updates.
  • .NET Framework 4.0: Compatible with Windows XP, Windows Server 2003, Windows Vista, Windows 7, Windows Server 2008, and Windows Server 2008 R2.
  • .NET Framework 4.5 to 4.8: Compatible with Windows 7, Windows 8, Windows 8.1, Windows 10, and corresponding Windows Server versions.

.NET Core vs. .NET Framework

.NET Core and .NET Framework are both cross-platform development frameworks developed by Microsoft. Here’s a brief comparison between .NET Core and .NET Framework:

  1. Cross-Platform Support
    • .NET Core: Designed to be cross-platform, it can run on Windows, Linux, and macOS.
    • .NET Framework: Primarily targeted at the Windows platform and cannot run directly on other operating systems.
  2. Open Source Nature
    • .NET Core: Completely open source, with its source code hosted on GitHub.
    • .NET Framework: While there are some open-source components and tools, the framework as a whole is not fully open source.
  3. Dependency and Deployment
    • .NET Core: Manages dependencies using NuGet, and applications can be self-contained in terms of required runtime libraries.
    • .NET Framework: Applications rely on the Global Assembly Cache (GAC), and developers need to ensure the required .NET Framework version is present on the target machine.
  4. Versioning and Updates
    • .NET Core: Has shorter release cycles with frequent updates, introducing new features and improvements with each update.
    • .NET Framework: Has longer release cycles with relatively slower updates, typically in the form of service packs or larger releases.
  5. API Support
    • .NET Core: Offers a more modern and streamlined set of APIs, some Windows-specific features might not be supported in .NET Core.
    • .NET Framework: Provides rich support for Windows APIs, enabling access to a wide range of Windows features.
  6. WPF and WinForms
    • .NET Core: Currently lacks native support for WPF and WinForms application development, but support can be achieved through certain tools and libraries.
    • .NET Framework: Offers full support for WPF and WinForms.
  7. Performance
    • .NET Core: Generally offers better performance compared to .NET Framework due to its lightweight design and cross-platform nature.
    • .NET Framework: Optimized for the Windows platform but might be relatively weaker in terms of cross-platform and performance aspects.

Venting: Why is writing documentation more exhausting than designing the UI…