How can I group windows to be raised as one?

Introduction

The following script allows selecting two windows, and while both windows are open, it will raise both windows when user focuses either one. For instance, if one links widows A and B , witching to either A or B will make both raise above other widows.

To stop the script you can use killall link_windows.py in terminal , or close and reopen one of the windows. You can also cancel execution by pressing close button X on either of the window-selection popup dialogs.

Potential tweaks:

  • multiple instances of the script can be used to group pairs of two windows. For example, if we have windows A,B,C,and D we can link A and B together, and link C and D together.
  • multiple windows can be grouped under one single window. For example, if I link window B to A, C to A, and D to A , that means if I always switch to A , I can raise all 4 windows at the same time.

Usage

Run the script as:

python link_windows.py

The script is compatible with Python 3 , so it can also run as

python3 link_windows.py

There are two command line options:

  • --quiet or -q , allows silencing down the GUI windows. With this option you can just click with mouse on any two windows , and the script will begin linking those.
  • --help or -h, prints the usage and description information.

The -h option produces the following information:

$ python3 link_windows.py  -h                                                                                            
usage: link_windows.py [-h] [--quiet]

Linker for two X11 windows.Allows raising two user selected windows together

optional arguments:
  -h, --help  show this help message and exit
  -q, --quiet  Blocks GUI dialogs.

Additional technical information can be viewed via pydoc ./link_windows.py , where ./ signifies that you must be in the same directory as the script.

Simple usage process for two windows:

  1. A popup will appear asking you to select a window #1 , press OK or hit Enter. The mouse pointer will turn in to a cross. Click on one of the windows you want to link.

  2. A second popup will appear asking you to select window #2,press OK or hit Enter. Again, the mouse pointer will turn into a cross.Click on the other window you want to link. After that execution will begin.

  3. Whenever you focus either window, the script will raise the other window up, but return focus to the one originally selected ( note - with a quarter of a second delay for best performance ) , thus creating feel that windows are linked together.

If you select the same window both times, the script will quit. If at any moment you click close button of popup dialog , the script will quit.

Script source

Also available as GitHub Gist

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Author: Sergiy Kolodyazhnyy
Date:  August 2nd, 2016
Written for: https://askubuntu.com/q/805515/295286
Tested on Ubuntu 16.04 LTS
"""
import gi
gi.require_version('Gdk', '3.0')
gi.require_version('Gtk', '3.0')
from gi.repository import Gdk, Gtk
import time
import subprocess
import sys
import argparse


def run_cmd(cmdlist):
    """ Reusable function for running shell commands"""
    try:
        stdout = subprocess.check_output(cmdlist)
    except subprocess.CalledProcessError:
        sys.exit(1)
    else:
        if stdout:
            return stdout


def focus_windows_in_order(first, second, scr):
    """Raise two user-defined windows above others.
       Takes two XID integers and screen object.
       Window with first XID will have the focus"""

    first_obj = None
    second_obj = None

    for window in scr.get_window_stack():
        if window.get_xid() == first:
            first_obj = window
        if window.get_xid() == second:
            second_obj = window

    # When this  function is called first_obj is alread
    # raised. Therefore we must raise second one, and switch
    # back to first
    second_obj.focus(int(time.time()))
    second_obj.get_update_area()
    # time.sleep(0.25)
    first_obj.focus(int(time.time()))
    first_obj.get_update_area()


def get_user_window():
    """Select two windows via mouse. Returns integer value of window's id"""
    window_id = None
    while not window_id:
        for line in run_cmd(['xwininfo', '-int']).decode().split('\n'):
            if 'Window id:' in line:
                window_id = line.split()[3]
    return int(window_id)


def main():
    """ Main function. This is where polling for window stack is done"""

    # Parse command line arguments
    arg_parser = argparse.ArgumentParser(
        description="""Linker for two X11 windows.Allows raising """ +
                    """two user selected windows together""")
    arg_parser.add_argument(
                '-q','--quiet', action='store_true',
                help='Blocks GUI dialogs.',
                required=False)
    args = arg_parser.parse_args()

    # Obtain list of two user windows
    user_windows = [None, None]
    if not args.quiet:
        run_cmd(['zenity', '--info', '--text="select first window"'])
    user_windows[0] = get_user_window()
    if not args.quiet:
        run_cmd(['zenity', '--info', '--text="select second window"'])
    user_windows[1] = get_user_window()

    if user_windows[0] == user_windows[1]:
        run_cmd(
            ['zenity', '--error', '--text="Same window selected. Exiting"'])
        sys.exit(1)

    screen = Gdk.Screen.get_default()
    flag = False

    # begin watching for changes in window stack
    while True:

        window_stack = [window.get_xid()
                        for window in screen.get_window_stack()]

        if user_windows[0] in window_stack and user_windows[1] in window_stack:

            active_xid = screen.get_active_window().get_xid()
            if active_xid not in user_windows:
                flag = True

            if flag and active_xid == user_windows[0]:
                focus_windows_in_order(
                    user_windows[0], user_windows[1], screen)
                flag = False

            elif flag and active_xid == user_windows[1]:
                focus_windows_in_order(
                    user_windows[1], user_windows[0], screen)
                flag = False

        else:
            break

        time.sleep(0.15)


if __name__ == "__main__":
    main()

Notes:

  • When running from command line, popup dialogs produce the following message: Gtk-Message: GtkDialog mapped without a transient parent. This is discouraged. These can be ignored.
  • Consult How can I edit/create new launcher items in Unity by hand? for creating a launcher or desktop shortcut for this script , if you desire to launch it with double-click
  • In order to link this script to a keyboard shortcut for easy access, consult How to add keyboard shortcuts?

Raise an arbitrary number of windows as one

The solution below will let you choose any combination of two, three or more windows to be combined and raised as one with a keyboard shortcut.

The script does its work with three arguments:

add

to add the active window to the group

raise

to raise the set group

clear

to clear the group, ready to define a new group

The script

#!/usr/bin/env python3
import sys
import os
import subprocess

wlist = os.path.join(os.environ["HOME"], ".windowlist")

arg = sys.argv[1]

if arg == "add":
    active = subprocess.check_output([
        "xdotool", "getactivewindow"
        ]).decode("utf-8").strip()
    try:
        currlist = open(wlist).read()
    except FileNotFoundError:
        currlist = []
    if not active in currlist:
        open(wlist, "a").write(active + "\n")
elif arg == "raise":
    group = [w.strip() for w in open(wlist).readlines()]
    [subprocess.call(["wmctrl", "-ia", w]) for w in group]
elif arg == "clear":
    os.remove(wlist)

How to use

  1. The script needs wmctrl and xdotool:

    sudo apt-get install wmctrl xdotool
    
  2. Copy the script above into an empty file, save it as groupwindows.py
  3. Test- run the script: open two terminal windows, run the command:

    python3 /absolute/path/to/groupwindows.py add
    

    in both of them. Cover them with other windows (or minimize them). Open a third terminal window, run the command:

    python3 /absolute/path/to/groupwindows.py raise
    

    The first two windows will be raised as one.

  4. If all works fine, create three custom shortcut keys: Choose: System Settings > "Keyboard" > "Shortcuts" > "Custom Shortcuts". Click the "+" and add the commands below to three separate shortcuts:

    on my system, I used:

    Alt+A, running the command:

    python3 /absolute/path/to/groupwindows.py add
    

    ...to add a window to the group.

    Alt+R, running the command:

    python3 /absolute/path/to/groupwindows.py raise
    

    ...to raise the group.

    Alt+C, running the command:

    python3 /absolute/path/to/groupwindows.py clear
    

    ...to clear the group

Explanation

The script works quite simply:

  • When run with the argument add, the script stores/adds the active window's window- id into a hidden file ~/.windowlist
  • When run with the argument raise, the script reads the file, raises the windows in the list with the command:

    wmctrl -ia <window_id>
    
  • When run with the argument clear, the script removes the hidden file ~/.windowlist.

Notes

  • The script will also work on minimized windows, it will un- minimize possibly minimized windows
  • If the set of windows is on another viewport, the script will switch to the corresponding viewport
  • The set is flexibel, you can always add other windows to the current set.

More flexibility?

As mentioned, the script above allows adding windows at any time to the grouped windows. The version below also allows removing any of the windows (at any time) from the grouped list:

#!/usr/bin/env python3
import sys
import os
import subprocess

wlist = os.path.join(os.environ["HOME"], ".windowlist")
arg = sys.argv[1]
# add windows to the group
if arg == "add":
    active = subprocess.check_output([
        "xdotool", "getactivewindow"
        ]).decode("utf-8").strip()
    try:
        currlist = open(wlist).read()
    except FileNotFoundError:
        currlist = []
    if not active in currlist:
        open(wlist, "a").write(active + "\n")
# delete window from the group
if arg == "delete":
    try:
        currlist = [w.strip() for w in open(wlist).readlines()]
    except FileNotFoundError:
        pass
    else:
        currlist.remove(subprocess.check_output([
            "xdotool", "getactivewindow"]).decode("utf-8").strip())      
        open(wlist, "w").write("\n".join(currlist)+"\n")
# raise the grouped windows
elif arg == "raise":
    group = [w.strip() for w in open(wlist).readlines()]
    [subprocess.call(["wmctrl", "-ia", w]) for w in group]
# clear the grouped window list
elif arg == "clear":
    os.remove(wlist)

The additional argument to run the script is delete, so:

python3 /absolute/path/to/groupwindows.py delete

deletes the active window from the grouped windows. To run this command, on my system, I set Alt+D as a shortcut.