I have this issue where Windows is sometimes randomly changing my primary display after a system restart. So I wanted to create a simple command-line application that would allow me to change the display settings on system startup - should be easy, right 🤔?
There are great tools like MultiMonitorTool that already provide this functionality, but they come with a lot of UI and utilities, that I don't need - I want a lightweight tool that I can include in my automated Windows setup.
I did some research and found various blog articles and forum entries about how to change the primary display. They (e.g. here and here) suggest to use the ChangeDisplaySettingsEx call to the user API (
After some testing, I just couldn't get it to work even though it's supposed to be one simple call to the winuser library. So I used API Monitor to see what API calls MultiMonitorTool makes. There are more than 50 calls to the winuser APIs alone. But I eventually found the
ChangeDisplaySettingsEx call and tried to replicate its content exactly (even byte by byte) - still no success.
After some more research and having a look at random source code snippets from GitHub, I finally found out how to change the primary monitor properly - and it's way more complicated than I could have ever imagined.
So before diving into the details - here it is: displays - a lightweight CLI tool and Rust library to change display properties like primary display, resolution and orientation. I developed the application in Rust using the winsafe crate, which basically creates a safe wrapper around the Windows API. However, I had to contribute various changes to that crate in order to implement the following API calls.
I am by no means an expert with regard to the Windows API. There are probably other calls that work as well and simplify some things. But I wanted to keep the tool generic so that it can be used to change the orientation, resolution, primary etc of any amount of monitors connected. So let's get started:
First, you have to find out the identifier of the display you want to make the new primary. In order to do that, you have to call EnumDisplayDevices multiple times with an increasing counter as a parameter in a loop. Stop looping as soon as a call returns an error.
After that, you can call EnumDisplaySettings (or the
Ex version) by passing the identifier to retrieve the so-called DEVMODE structure. This struct contains information about the display, like position, which is required for the following calls.
Now we are finally ready to call
ChangeDisplaySettingsEx, right? Turns out you need to call this for every display that is connected and active (I will elaborate on the reason for this later), which means you also gotta call
EnumDisplaySettings for every monitor. Finally, we can execute the program... And nothing happens.
Turns out you have to call
ChangeDisplaySettingsEx one more time while passing NULL to every parameter, in order to actually "commit" the changes (the monitor will flicker and apply the new properties afterwards).
MultiMonitorTool does it the same way (other than it issues some additional calls, that are not important for this) - so did we solve the problem? Nope, still doesn't work 😐. After comparing the parameters of other's tools' calls as well as mine', I noticed that the position properties of the
ChangeDisplaySettingsEx call are different.
And here comes the weird part: The values change depending on which monitor I set as primary. So there is clearly some magic happening, that calculates new position values. This seems to be the difference between the other's approach and my broken one.
Finally, I found a hint in some Chinese codebase that hinted at the virtual screen model that Windows uses. Turns out that the primary display also defines the origin of the coordinate system that the other monitors use when specifying the position. So instead of just passing the position from the
EnumDisplaySettings call, we need to recalculate it accordingly for each active display. To figure that out took me waaay to long - I wish Microsoft had some information about this in their documentation.
Implementing this is not hard: The new primary display always is located at position (0, 0). The other ones should be moved by the negative position of the old primary display to align them to the new origin:
new_display_position = -old_primary_position + old_display_position;
And voila - it works just fine now.