How can I lock an application (and all its new windows) into a specific workspace?

IMPORTANT EDIT

Below a rewritten version of the script from the first answer (below). The differences:

  • The script now is extremely low on resources (like it should be with background scripts). The actions are now arranged to act if (and only if) they are needed. The loop does practically nothing but check for new windows to appear.
  • Bot the WM_CLASS and the targeted workspace are now arguments to run the script. Only use either the first or the second (identifying) part of the WM_CLASS (see further below: how to use)
  • The script now keeps focus on the currently active window (actually re- focusses in a split second)
  • When the script starts, it shows a notification (example gedit):

    enter image description here

The script

#!/usr/bin/env python3
import subprocess
import sys
import time
import math

app_class = sys.argv[1]
ws_lock = [int(n)-1 for n in sys.argv[2].split(",")]

def check_wlist():
    # get the current list of windows
    try:
        raw_list = [
            l.split() for l in subprocess.check_output(
                ["wmctrl", "-lG"]
                ).decode("utf-8").splitlines()
            ]
        ids = [l[0] for l in raw_list]
        return (raw_list, ids)
    except subprocess.CalledProcessError:
        pass

def get_wssize():
    # get workspace size
    resdata = subprocess.check_output(["xrandr"]).decode("utf-8").split()
    i = resdata.index("current")
    return [int(n) for n in [resdata[i+1], resdata[i+3].replace(",", "")]]

def get_current(ws_size):
    # vector of the current workspace to origin of the spanning desktop
    dt_data = subprocess.check_output(
        ["wmctrl", "-d"]
        ).decode("utf-8").split()
    curr = [int(n) for n in dt_data[5].split(",")]
    return (int(curr[0]/ws_size[0]), int(curr[1]/ws_size[1]))

def get_relativewinpos(ws_size, w_data):
    # vector to the application window, relative to the current workspace
    xpos = int(w_data[2]); ypos = int(w_data[3])
    xw = ws_size[0]; yw = ws_size[1]
    return (math.ceil((xpos-xw)/xw), math.ceil((ypos-yw)/yw))

def get_abswindowpos(ws_size, w_data):
    # vector from the origin to the current window's workspace (flipped y-axis)
    curr_pos = get_current(ws_size)
    w_pos = get_relativewinpos(ws_size, w_data)
    return (curr_pos[0]+w_pos[0], curr_pos[1]+w_pos[1])

def wm_class(w_id):
    # get the WM_CLASS of new windows
    return subprocess.check_output(
        ["xprop", "-id", w_id.strip(), "WM_CLASS"]
        ).decode("utf-8").split("=")[-1].strip()

ws_size = get_wssize()
wlist1 = []
subprocess.Popen(["notify-send", 'workspace lock is running for '+app_class])

while True:
    # check focussed window ('except' for errors during "wild" workspace change)
    try:
        focus = subprocess.check_output(
            ["xdotool", "getwindowfocus"]
            ).decode("utf-8")
    except subprocess.CalledProcessError:
        pass
    time.sleep(1)
    wdata = check_wlist() 
    if wdata !=  None:
        # compare existing window- ids, checking for new ones
        wlist2 = wdata[1]
        if wlist2 != wlist1:
            # if so, check the new window's class
            newlist = [[w, wm_class(w)] for w in wlist2 if not w in wlist1]
            valids = sum([[l for l in wdata[0] if l[0] == w[0]] \
                          for w in newlist if app_class in w[1]], [])
            # for matching windows, check if they need to be moved (check workspace)
            for w in valids:
                abspos = list(get_abswindowpos(ws_size, w))
                if not abspos == ws_lock:
                    current = get_current(ws_size)
                    move = (
                        (ws_lock[0]-current[0])*ws_size[0],
                            (ws_lock[1]-current[1])*ws_size[1]-56
                        )
                    new_w = "wmctrl -ir "+w[0]+" -e "+(",").join(
                        ["0", str(int(w[2])+move[0]),
                         str(int(w[2])+move[1]), w[4], w[5]]
                        )
                    subprocess.call(["/bin/bash", "-c", new_w])
                    # re- focus on the window that was focussed
                    if not app_class in wm_class(focus):
                        subprocess.Popen(["wmctrl", "-ia", focus])
        wlist1 = wlist2

How to use

  1. The script needs both wmctrl and xdotool:

    sudo apt-get install wmctrl xdotool
    
  2. Copy the script above into an empty file, save it as lock_towspace.py

  3. Of your specific application, find out the WM_CLASS: open your application, run in a terminal:

    xprop WM_CLASS and click on the window of the application
    

    The output will look like (in your case):

    WM_CLASS: WM_CLASS(STRING) = "sun-awt-X11-XFramePeer", "MATLAB R2015a - academic use"
    

    Either use the first or the second part in the command to run the script.

  4. The command to run the script then is:

    python3 /path/to/lock_towspace.py "sun-awt-X11-XFramePeer" 2,2
    

    In the command, the last section; 2,2 is the workspace where you want to lock the application to (without spaces: (!) column, row), in "human" format; the first column/row is 1,1

  5. Test the script by running it. While running, open your application and let it produce windows as usual. All windows should appear on the targeted workspace, as set in the command.

OUTDATED ANSWER:

(second) TEST VERSION

The script below locks a specific application to its initial workspace. If the script is started, it determines on which workspace the application resides. All additional windows the application produces will be moved to the same workspace in a split second.

The focus issue is solved by automatically re- focussing on the window that was focussed before the additional window was produced.

The script

#!/usr/bin/env python3
import subprocess
import time
import math

app_class = '"gedit", "Gedit"'

def get_wssize():
    # get workspace size
    resdata = subprocess.check_output(["xrandr"]).decode("utf-8").split()
    i = resdata.index("current")
    return [int(n) for n in [resdata[i+1], resdata[i+3].replace(",", "")]]

def get_current(ws_size):
    # get vector of the current workspace to the origin of the spanning desktop (flipped y-axis)
    dt_data = subprocess.check_output(["wmctrl", "-d"]).decode("utf-8").split(); curr = [int(n) for n in dt_data[5].split(",")]
    return (int(curr[0]/ws_size[0]), int(curr[1]/ws_size[1]))

def get_relativewinpos(ws_size, w_data):
    # vector to the application window, relative to the current workspace
    xw = ws_size[0]; yw = ws_size[1]
    return (math.ceil((w_data[1]-xw)/xw), math.ceil((w_data[2]-yw)/yw))

def get_abswindowpos(ws_size, w_data):
    curr_pos = get_current(ws_size)
    w_pos = get_relativewinpos(ws_size, w_data)
    return (curr_pos[0]+w_pos[0], curr_pos[1]+w_pos[1])

def wm_class(w_id):
    return subprocess.check_output(["xprop", "-id", w_id, "WM_CLASS"]).decode("utf-8").split("=")[-1].strip()

def filter_windows(app_class):
    # find windows (id, x_pos, y_pos) of app_class
    try:
        raw_list = [l.split() for l in subprocess.check_output(["wmctrl", "-lG"]).decode("utf-8").splitlines()]
        return [(l[0], int(l[2]), int(l[3]), l[4], l[5]) for l in raw_list if wm_class(l[0]) == app_class]
    except subprocess.CalledProcessError:
        pass

ws_size = get_wssize()
init_window = get_abswindowpos(ws_size, filter_windows(app_class)[0])
valid_windows1 = filter_windows(app_class)

while True:
    focus = subprocess.check_output(["xdotool", "getwindowfocus"]).decode("utf-8")
    time.sleep(1)
    valid_windows2 = filter_windows(app_class)
    if all([valid_windows2 != None, valid_windows2 != valid_windows1]):
        for t in [t for t in valid_windows2 if not t[0] in [w[0] for w in valid_windows1]]:
            absolute = get_abswindowpos(ws_size, t)
            if not absolute == init_window:
                current = get_current(ws_size)
                move = ((init_window[0]-current[0])*ws_size[0], (init_window[1]-current[1])*ws_size[1]-56)
                new_w = "wmctrl -ir "+t[0]+" -e "+(",").join(["0", str(t[1]+move[0]), str(t[2]+move[1]), t[3], t[4]])
                subprocess.call(["/bin/bash", "-c", new_w])
            focus = str(hex(int(focus)))
            z = 10-len(focus); focus = focus[:2]+z*"0"+focus[2:]
            if not wm_class(focus) == app_class:
                subprocess.Popen(["wmctrl", "-ia", focus])
        valid_windows1 = valid_windows2

How to use

  1. The script needs both wmctrland xdotool

    sudo apt-get install wmctrl xdotool
    
  2. Copy the script into an empty file, save it as keep_workspace.py

  3. determine your application's `WM_CLASS' by opening the application, then open a terminal and run the command:

    xprop WM_CLASS
    

    Then click on your application's window. Copy the output, looking like "sun-awt-X11-XFramePeer", "MATLAB R2015a - academic use" in your case, and place it between single quotes in the head section of the script, as indicated.

  4. Run the script with the command:

    python3 /path/to/keep_workspace.py
    

If it works as you like, I'll add a toggle function. Although it works already for a few hours on my system, bu it might need some tweaking first however.

Notes

Although you should not notice it, the script does add some processor load to the system. On my elderly system I noticed an increase of 3-10%. If you like how it works, I will probably further tweak it to reduce the load.

The script, as it is, assumes the secundary windows are of the same class as the main window, like you indicated in a comment. With a (very) simple change, the secondary windows can be of another class however.

Explanation

Although probably not very interesting for an average reader, the script works by calculating in vectors. On startup, the script calculates:

  • the vector from the origin to the current workspace with the output of wmctrl -d
  • the vector to the application's window, relative to the current workspace, by the output of wmctrl -lG
  • From these two, the script calculates the absolute position of the application's window on the spanning desktop (all workspaces in one matrix)

From then on, the script looks for new windows of the same application, with the output of xprop WM_CLASS, looks up their position in the same way as above and moves them to the "original" workspace.

Since the newly created window "stole" the focus from the last used window the user was working on, the focus is subsequently set to the window that had focus before.