devxlogo

Gain Control of Your Desktop with WindowMover

Gain Control of Your Desktop with WindowMover

indowMover is a little project originally created for a study that involved tracking the gaze of subjects when presented with various images. The study used a special eye-tracker device that comes with control software. The device itself is excellent, but the control software leaves a lot to be desired. In addition to serious memory management issues, it requires images to be presented on the primary display, while the control software runs on a secondary display. This is a problem, because test subjects should not be distracted by other windows popping up during an experiment, or even have to see the taskbar in the background. WindowMover solves the problem by ensuring that only the image presentation runs on the primary display; it moves all other windows to the secondary display automatically. WindowMover works on Windows only, but you could apply the concept and the techniques to other graphical operating systems.

How WindowMover Works
WindowMover works by constantly monitoring all the visible top-level windows. It keeps a list of target windows that should remain on the primary display. When a window shows up on the primary display WindowMover checks if it’s on the target list; if not, WindowMover moves the window to the secondary display. That sounds simple enough. I originally estimated it would take me a few hours. It turned out to be more complicated than that. Maximized windows require special treatment, and different screen dimensions for the primary and secondary displays require special adjustment. WindowMover handles such problems automatically.

Figure 1 shows two desktops. The right (primary) desktop is initially empty, but WindowMover has been configured to move FireFox from the secondary to the primary display, as shown in Figure 2.

 
Figure 1. Before Move: In this screenshot, the primary display on the right is empty, but WindowMover has been configured to move FireFox windows to the primary display.
 
Figure 2. After Move: Here, WindowMover has detected the FireFox window and moved it to the primary desktop. Typically, this essentially happens instantaneously.

WindowMover’s Design
WindowMover is a multi-threaded Python program that uses the win32extensions package for Python to access the Win32 APIs. It can run for a certain number of seconds or indefinitely. For development and testing purposes it is convenient to run it for a limited number of seconds. When you are sure it is working you may run it with no time limit. A single file, WindowMover.py, contains the entire program. The program exposes one class called MonitorThread, which runs in its own thread and does the heavy lifting of checking all the top-level windows and moving them as necessary. MonitorThread uses two utility functions, called _getTopLevelWindows() and _enumWindowsCallback(), which respectively retrieve the list of top-level windows and call a function for each retrieved window in the list.

Enumerating Top-Level Windows
The Windows API allows enumerating top-level windows via a callback API. You call the EnumWindows() function and provide a callback function, which Windows will call for each window, passing the window handle as a parameter. In general, callback interfaces are efficient but not terribly convenient to work with. It is usually more convenient to work with a list of all the objects that you want to process. In this case, the list is not too big to fit into memory, and you don’t need to process the Windows as soon as possible, so the list approach works fine. The _getTopLevelWindows function retrieves the list:

   def _getTopLevelWindows():     """Get all top-level Windows (visible and invisible)"""     windows = []     win32gui.EnumWindows(_enumWindowsCallback, windows)     return windows

The name of the module from the win32extensions package that deals with Windows and other user interface objects is win32gui. The EnumWindows() function takes the callback function (_enumWindowsCallback in this case) and a context object as parameters. Here, the context object is an (initially) empty list that the callback will populate. In the scope of one call to _getTopLevelWindows() the _enumWindowsCallback function will be invoked multiple times (once for each window in the list):

   def _enumWindowsCallback(hwnd, windows):        className = win32gui.GetClassName(hwnd)     text = win32gui.GetWindowText(hwnd)     windows.append((hwnd, className, text))

The callback function is invoked with the hwnd (window handle) that uniquely identifies every top-level window. The hwnd provides the function with additional information about each window (its class name and title), which it stores in the list passed in as the context object (the second parameter in the preceding code).

Module Variables
Module variables in Python might look like other languages’ global variables, but in Python every name at the module level is scoped by the module name, so there are no global variables. The default_targets module variable defined below contains the list of windows that WindowMover can move from one display to another:

   # All windows on this list will be kept on    # the primary display or moved to the secondary    # display based on the 'keep' parameter below   default_targets = []

The keep variable determines whether the target windows should be moved or whether they should remain in their original display while all other windows get moved. In some testing situations it helps to switch back and forth:

   # determine if the targets on the primary display   # should be kept there or moved to the secondary display.   keep = True

The check_delay variable defined below controls the delay between successive iterations. Finding the right delay is very important; a delay that’s too short will negatively impact system performance, because it will use up resources by constantly checking every window. A delay that’s too long lets undesirable windows show up on the wrong display without moving them to the other display quickly enough:

   # Determine how often to check for targets (in seconds).   # As this number approaches zero program responsiveness    # improves (windows move almost immediately to the other   # display) but it can also hog the CPU (the computer    # won't be able to do much else)   check_delay = 0.1  # in seconds

The height_difference variable helps to adjust the location of a window moving between displays with different resolutions:

   # This parameter is very important when resolutions    # of the two displays are different.   # If the primary display is taller than the secondary,    # then the top of the secondary display doesn't start at 0!   # The top of the secondary will actually be the difference    # in heights.   height_difference = 177

It turns out that height_difference works in a very unintuitive way; you’ll see more about that later.

You need to get the actual dimensions of the stretched desktop (across both displays), which you can do using two Windows API functions. GetDesktopWindow() returns the window handle of the desktop itself (which is also a window), and GetWindowRect() gets the left/top coordinate (always 0, 0 for the desktop) and the width/height:

   monitor_width, monitor_height =       GetWindowRect(GetDesktopWindow())[2:]

The main() Function
The main() function launches the MonitorThread and determines whether it should run forever, or only for the specified number of seconds:

   def main(seconds=10.0):     print 'Monitor dimensions:',        (monitor_width, monitor_height)     print       t = MonitorThread(verbose=False)     t.start()     if seconds == 0.0:       # run forever       while (True):         time.sleep(5.0)          time.sleep(seconds)     t.stop()     t.join()

The main() function accepts the number of seconds to run (10 seconds by default). If the parameter is 0 the program goes into an infinite loop, otherwise it sleeps for the specified number of seconds, letting the monitor thread do its thing, and eventually stops the thread.

The ThreadMonitor Class
The real work takes place in the ThreadMonitor class, which is a subclass of threading.Thread from Python’s standard threading library module. It exposes the mandatory run() method that runs in a separate thread. Here’s the code:

   class MonitorThread(threading.Thread):     def __init__(self, verbose=False, targets=None):       threading.Thread.__init__(self)       self.targets = targets       if self.targets == None:         self.targets = default_targets          self.verbose = verbose       self.done = threading.Event()

The __init__ method takes two arguments (the ‘self’ argument is an implicit one like the ‘this’ pointer in C++, and is not considered an argument): verbose and targets, set to appropriate defaults, and keeps them for later reference. The verbose argument determines how verbose the monitor’s output should be. The targets argument is the list of windows to monitor; if it’s the default None, the method uses the default_targets module variable). The most important job of the __init__ method is to fire the done event. This synchronization object allows the main thread to stop the monitor thread (used only when running for a specific number of seconds).

The run() and stop() methods
These methods control the lifetime of the worker thread. The run() method calls the _checkTargets() method repeatedly as long as the done event is not set, and sleeps between each iteration so it doesn’t monopolize machine resources. This method launches on a separate thread when the main thread calls start().

   def run(self):       while not self.done.isSet():         self._checkTargets()         self.done.wait(0.1)         time.sleep(check_delay)       print 'Done'

The stop() method sets the done event that the run() method checks. The main thread calls it to stop the thread’s operation cleanly.

   def stop(self):      self.done.set()

The _moveWindow() Method
Finally, with all the framework code in place, here’s how the program actually moves a window:

   def _moveWindow(self, hwnd, dx=-monitor_width, dy=height_difference) :          r = GetWindowRect(hwnd)          # calculate new position       x =  r[0] + dx        y =  r[1] + dy                # calculate width/height      width  = r[2]-r[0]       height = r[3]-r[1]         MoveWindow(hwnd, x, y, width, height, True)  

As you saw earlier, the _checkTargets() method calls _moveWindow() when it needs to move a window. The method accepts the handle (hwnd) of the window to be moved and two values that determine how much to offset the window horizontally (dx) and vertically (dy). The default for dx is -monitor_width, which means move it from the right display to the equivalent location in the left display. The default for dy is height_difference, which moves the window vertically as needed in the secondary display if a height difference exists between the displays.

The method starts by calling the GetWindowRect() API function, which returns a tuple of the form (left, top, right, bottom). It calculates the new position by adding dx and dy to the left and top coordinates and then calculates the width and height, which remain the same. Finally, it actually moves the window by calling the MoveWindow() API function. Note the different coordinate systems (top, left, right, bottom) and (top, left, width, height). It’s easy to become confused (which produces interesting bugs) by passing right and bottom instead of width and height.

The _isMaximized() Method
The _isMaximized() method is a utility function that should have been in the win32gui API wrappers. The Win32 API itself has a function called IsZoomed that tells you whether a window is maximized, but it’s missing from win32gui. Luckily, it is pretty easy to reproduce. A window has several generic parts: a title bar, a border, and a client area. The title bar is the strip at the top of the window that contains the title and the minimize/maximize/close buttons. The border is the thin line that surrounds the window and allows you to resize the window by dragging it. The client area is everything in the middle, where all the controls and the content of the window are placed. All these parts are optional, and there are many ways to configure a window, so any particular window may have any combination of these parts, with different styles. It is also possible to override the behavior of these parts with some extra work.

     def _isMaximized(self, h):      cr = GetClientRect(h)       clientWidth = cr[2]       return clientWidth == monitor_width

It’s important to understand what happens when a window is maximized. The window’s size changes so that its client area and the title bar exactly cover the entire screen of the display the window resides in. The border disappears. To determine whether a window is maximized, you can check whether the width of the client area is exactly the width of the monitor. The GetClientRect() API function provides the boundaries of the client area. Unfortunately, it confusingly returns left, top, width, height, while GetWindowRect() returns left, top, right, bottom.

This function is not 100-percent foolproof. A user may resize a window without maximizing it so that its client area exactly covers the monitor; however that’s rare, and for WindowMover it is not a use case worth pursuing further.

The _checkTargets() Method
This method contains WindowMover’s main algorithm. The full source code for _checkTargets() is in Listing 1, but I’ll explain it in smaller chunks:

   def _checkTargets(self):       if self.verbose:         print '_checkTargets() here'              windows = _getTopLevelWindows()       for i, w in enumerate(windows):         # Ignore minimized and hidden windows         if not win32gui.IsWindowVisible(w[0]) or                     win32gui.IsIconic(w[0]):             continue

checkTargets_() prints a little greeting (if in verbose mode) and then calls _getTopLevelWindows() to get the list of top-level windows. Next, it iterates over all the windows using the enumerate() function. This built-in Python function iterates over any iterable object, such as a list or dictionary, and for each item it returns a value pair containing a sequential number and the item itself. You’ll find enumerate() very convenient when you want to keep track of how many items you have iterated over so far and what the current item index is. Inside the loop it checks whether the current window is minimized or hidden; if so, it simply continues to the next window (there’s no need to move hidden or minimized windows because they don’t show up on the primary display).

         if w[1] == 'Shell_TrayWnd': # Ignore the tray           continue         move = False         is_target = False         for t in self.targets:           # Check both Window class (w[1]) and title (w[2])           if t in w[1] or t in w[2]:             is_target = True             break                      if keep and not is_target:           move = True         if not keep and is_target:           move= True

At the top of the preceding code fragment, you’ll see another check—this time for the Shell_TrayWnd. This special window is the system tray and should not be moved. Next, the code initializes the move and is_target variables to False, and then determines whether the current window is a target by iterating the target list, comparing the current window’s class and title against each target window. If either matches, the current window is considered a valid target for moving. Note that the comparison checks whether the target is contained in the window’s class or title. For example, if the target is cmd.exe, any window whose title contains cmd.exe will match the target. This lets you specify target windows in a flexible way that’s less prone to mismatches. After determining that the current window is a target, there is no need to keep checking the other targets, so the code breaks out of the loop.

The final decision about whether to move the current window depends on the two flag variables: is_target and keep. If the current window is a target (is_Target is True) and keep is False, it means that the current window should be moved. If the current window is not a target (is_Target is False), but keep is True, it means that target windows should be kept on the primary display, but the current window should be moved. In all other cases the current window should remain in place.

When a window is a candidate for moving the next step is to find out if the window is maximized. If so, calculate the border width by subtracting the width of the client rectangle (which doesn’t include the border) from the window rectangle (which does include the border) and dividing it by two, because the window rectangle includes the width of both the left and right borders:

        if move:                     # check on which monitor           r = GetWindowRect(w[0])              # move to secondary if necessary           borderWidth = 0           maximized = self._isMaximized(w[0])           if maximized:             cr = GetClientRect(w[0])             borderWidth = (r[2] - r[0] - cr[2]) / 2

The final test is to check if the window is actually in the primary display. In this case the primary display is configured to be the right-hand monitor in the system. This means that the horizontal screen coordinates of the primary display are between 0 and monitor_width-1 and the horizontal screen coordinates of the secondary display are between —monitor_width and -1. The check itself just makes sure the left coordinate of the window is greater or equal to —borderWidth. For a non-maximized window the borderWidth is 0, so it’s equivalent to check if the borderWidth is greater or equal to 0. For maximized windows, the border stretches outside the primary display and the program accounts for it. That seems unintuitive, because maximized windows seem to have no border, but when you query their size with GetWindowRect() their left co-ordinate is —borderWidth and the client rectangle covers the entire screen—making it only seem as if there is no border. You need to take this little quirk into account when detecting whether the window is in the primary display. For normal windows you don’t need to check for —borderWidth. Therefore, to use a single check that works for both types of windows, WindowMover sets the borderWidth variable to 0 for normal windows even though they have a border (note that it doesn’t actually set the width of the visual border to 0).

Finally, if the window is indeed on the primary display it gets moved using the _moveWindow() function. If the window is maximized there are some additional gymnastics that change its state (because maximized windows can’t be moved) and then maximize the window again after the move.

           # Window is in primary display           if r[0] >= -borderWidth:             if maximized:               ShowWindow(w[0], win32con.SW_HIDE)               ShowWindow(w[0], win32con.SW_SHOWNOACTIVATE)               self._moveWindow(w)               ShowWindow(w[0], win32con.SW_MAXIMIZE)             else:               self._moveWindow(w)

Swatting Some Odd Bugs
One day, after WindowMover had been running successfully, an urgent call informed me that WindowMover had gone crazy. All the windows, including the Task Manager, were flickering all over the place. The experimenters couldn’t even shut down WindowMover itself. To make matters worse I had set up WindowMover to auto-start, so even restarting the computer didn’t help. After several reboots they finally managed to click on the WindowMover close button and shut the program down before it could begin its damage.

I couldn’t figure out what could make WindowMover behave like that. Even if there were some error in the code, it should have perhaps just moved a few windows it wasn’t supposed to move, or kept some unnecessary windows on the primary display. But eventually, I realized what happened. You can rearrange the monitor position using Windows’ display settings dialog (see Figure 3 and Figure 4).

 
Figure 3. Specifying Primary Monitor: You can specify which monitor is the primary monitor and which is the secondary monitor using Windows’ display settings.
 
Figure 4. Specifying Secondary Monitor: Here, the secondary monitor is on the left, where WindowMover expected it to be.

And someone had done exactly that—moved the primary display to the left-hand side—so WindowMover’s expected horizontal coordinates were completely invalid. The primary display horizontal coordinates still ranged from 0 to monitor_width-1, but, with the secondary monitor on the right, the secondary display now ranges from monitor_width to 2*monitor_width-1. See Figure 5 and Figure 6 for the before and after state as they relate to the horizontal coordinates.

 
Figure 5. Primary Display on Right:The primary display’s left co-ordinate is always 0 so the secondary display’s right coordinate is -1 (immediately to the left of the primary display).
 
Figure 6. Primary Display on Left: Here, the primary display’s right co-ordinate is 1279, so the secondary display’s left coordinate is 1280 (immediately to the right of the primary display).

Obviously, the algorithm is sensitive to the relative locations of the primary display and the secondary display. The change to the display settings meant that when WindowMover moved the windows, they ended up in the wrong places.

Future Directions
WindowMover has a very specific goal, but some of its code can be useful in a more general context for other UI automation tasks. In addition, while WindowMover operates solely on the main desktop, Windows actually has multiple desktops. For example, the Windows login screen runs on a different desktop. Unfortunately WindowMover can’t run at login time. That’s important because for the eye-tracker project the primary display should eventually be located in a separate room from the secondary display. The keyboard would be with the secondary display. That arrangement means that if the login window pops up on the primary display there is no easy way for experimenters to log in; they would have to log in blindly from the secondary display, or send someone to the room with the primary display and coordinate over the phone. The correct solution is to make the login window show up on the secondary display, but doing that requires making WindowMover a windows service. Windows services can be executed very early and can run without a logged-in user.

As an example of how code from WindowMover can serve as the basis of other UI automation tasks, I used the main loop logic that checks all the top-level windows in another mini-project, called “clicker.” The intent behind clicker was to automatically dispose of the error dialog boxes that showed up during test-program runs. The errors were logged, but the modal dialog boxes prevented the test from continuing. Clicker took care of the problem by detecting these specific dialog boxes, determining the appropriate button, and then clicking it programmatically. Your imagination is the limit. The win32gui and other Win32 extension modules provide excellent coverage of the Win32 API, and Python makes it easy to experiment interactively.

devxblackblue

About Our Editorial Process

At DevX, we’re dedicated to tech entrepreneurship. Our team closely follows industry shifts, new products, AI breakthroughs, technology trends, and funding announcements. Articles undergo thorough editing to ensure accuracy and clarity, reflecting DevX’s style and supporting entrepreneurs in the tech sphere.

See our full editorial policy.

About Our Journalist