|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +import os |
| 4 | +import socket |
| 5 | +import selectors |
| 6 | +import threading |
| 7 | +from argparse import ArgumentParser |
| 8 | +import i3ipc |
| 9 | + |
| 10 | +SOCKET_FILE = '/tmp/i3-cycle-focus' |
| 11 | +MAX_WIN_HISTORY = 16 |
| 12 | +UPDATE_DELAY = 2.0 |
| 13 | + |
| 14 | + |
| 15 | +class FocusWatcher: |
| 16 | + |
| 17 | + def __init__(self): |
| 18 | + self.i3 = i3ipc.Connection() |
| 19 | + self.i3.on('window::focus', self.on_window_focus) |
| 20 | + self.listening_socket = socket.socket(socket.AF_UNIX, |
| 21 | + socket.SOCK_STREAM) |
| 22 | + if os.path.exists(SOCKET_FILE): |
| 23 | + os.remove(SOCKET_FILE) |
| 24 | + self.listening_socket.bind(SOCKET_FILE) |
| 25 | + self.listening_socket.listen(1) |
| 26 | + self.window_list = [] |
| 27 | + self.window_list_lock = threading.RLock() |
| 28 | + self.focus_timer = None |
| 29 | + self.window_index = 1 |
| 30 | + |
| 31 | + def update_windowlist(self, window_id): |
| 32 | + with self.window_list_lock: |
| 33 | + if window_id in self.window_list: |
| 34 | + self.window_list.remove(window_id) |
| 35 | + self.window_list.insert(0, window_id) |
| 36 | + if len(self.window_list) > MAX_WIN_HISTORY: |
| 37 | + del self.window_list[MAX_WIN_HISTORY:] |
| 38 | + self.window_index = 1 |
| 39 | + |
| 40 | + def get_valid_windows(self): |
| 41 | + tree = self.i3.get_tree() |
| 42 | + if args.active_workspace: |
| 43 | + return set(w.id for w in tree.find_focused().workspace().leaves()) |
| 44 | + elif args.visible_workspaces: |
| 45 | + ws_list = [] |
| 46 | + w_set = set() |
| 47 | + for item in self.i3.get_outputs(): |
| 48 | + ws_list.append(item["current_workspace"]) |
| 49 | + for ws in tree.workspaces(): |
| 50 | + if str(ws.num) in ws_list: |
| 51 | + for w in ws.leaves(): |
| 52 | + w_set.add(w.id) |
| 53 | + return w_set |
| 54 | + else: |
| 55 | + return set(w.id for w in tree.leaves()) |
| 56 | + |
| 57 | + def on_window_focus(self, i3conn, event): |
| 58 | + if args.ignore_float and (event.container.props.floating == "user_on" or |
| 59 | + event.container.props.floating == "auto_on"): |
| 60 | + return |
| 61 | + if UPDATE_DELAY != 0.0: |
| 62 | + if self.focus_timer is not None: |
| 63 | + self.focus_timer.cancel() |
| 64 | + self.focus_timer = threading.Timer(UPDATE_DELAY, |
| 65 | + self.update_windowlist, |
| 66 | + [event.container.props.id]) |
| 67 | + self.focus_timer.start() |
| 68 | + else: |
| 69 | + self.update_windowlist(event.container.props.id) |
| 70 | + |
| 71 | + def launch_i3(self): |
| 72 | + self.i3.main() |
| 73 | + |
| 74 | + def launch_server(self): |
| 75 | + selector = selectors.DefaultSelector() |
| 76 | + |
| 77 | + def accept(sock): |
| 78 | + conn, addr = sock.accept() |
| 79 | + selector.register(conn, selectors.EVENT_READ, read) |
| 80 | + |
| 81 | + def read(conn): |
| 82 | + data = conn.recv(1024) |
| 83 | + if data == b'switch': |
| 84 | + with self.window_list_lock: |
| 85 | + windows = self.get_valid_windows() |
| 86 | + for window_id in self.window_list[self.window_index:]: |
| 87 | + if window_id not in windows: |
| 88 | + self.window_list.remove(window_id) |
| 89 | + else: |
| 90 | + if self.window_index < (len(self.window_list) - 1): |
| 91 | + self.window_index += 1 |
| 92 | + else: |
| 93 | + self.window_index = 0 |
| 94 | + self.i3.command('[con_id=%s] focus' % window_id) |
| 95 | + break |
| 96 | + elif not data: |
| 97 | + selector.unregister(conn) |
| 98 | + conn.close() |
| 99 | + |
| 100 | + selector.register(self.listening_socket, selectors.EVENT_READ, accept) |
| 101 | + |
| 102 | + while True: |
| 103 | + for key, event in selector.select(): |
| 104 | + callback = key.data |
| 105 | + callback(key.fileobj) |
| 106 | + |
| 107 | + def run(self): |
| 108 | + t_i3 = threading.Thread(target=self.launch_i3) |
| 109 | + t_server = threading.Thread(target=self.launch_server) |
| 110 | + for t in (t_i3, t_server): |
| 111 | + t.start() |
| 112 | + |
| 113 | + |
| 114 | +if __name__ == '__main__': |
| 115 | + parser = ArgumentParser(prog='i3-cycle-focus.py', |
| 116 | + description=""" |
| 117 | + Cycle backwards through the history of focused windows (aka Alt-Tab). |
| 118 | + This script should be launched from ~/.xsession or ~/.xinitrc. |
| 119 | + Use the `--history` option to set the maximum number of windows to be |
| 120 | + stored in the focus history (Default 16 windows). |
| 121 | + Use the `--delay` option to set the delay between focusing the |
| 122 | + selected window and updating the focus history (Default 2.0 seconds). |
| 123 | + Use a value of 0.0 seconds to toggle focus only between the current |
| 124 | + and the previously focused window. Use the `--ignore-floating` option |
| 125 | + to exclude all floating windows when cycling and updating the focus |
| 126 | + history. Use the `--visible-workspaces` option to include windows on |
| 127 | + visible workspaces only when cycling the focus history. Use the |
| 128 | + `--active-workspace` option to include windows on the active workspace |
| 129 | + only when cycling the focus history. |
| 130 | +
|
| 131 | + To trigger focus switching, execute the script from a keybinding with |
| 132 | + the `--switch` option.""") |
| 133 | + parser.add_argument('--history', dest='history', |
| 134 | + help='Maximum number of windows in the focus history', |
| 135 | + type=int) |
| 136 | + parser.add_argument('--delay', dest='delay', |
| 137 | + help='Delay before updating focus history', |
| 138 | + type=float) |
| 139 | + parser.add_argument('--ignore-floating', dest='ignore_float', |
| 140 | + action='store_true', help='Ignore floating windows ' |
| 141 | + 'when cycling and updating the focus history') |
| 142 | + parser.add_argument('--visible-workspaces', dest='visible_workspaces', |
| 143 | + action='store_true', help='Include windows on visible ' |
| 144 | + 'workspaces only when cycling the focus history') |
| 145 | + parser.add_argument('--active-workspace', dest='active_workspace', |
| 146 | + action='store_true', help='Include windows on the ' |
| 147 | + 'active workspace only when cycling the focus history') |
| 148 | + parser.add_argument('--switch', dest='switch', action='store_true', |
| 149 | + help='Switch to the previous window', default=False) |
| 150 | + args = parser.parse_args() |
| 151 | + |
| 152 | + if args.history: |
| 153 | + MAX_WIN_HISTORY = args.history |
| 154 | + if args.delay: |
| 155 | + UPDATE_DELAY = args.delay |
| 156 | + else: |
| 157 | + if args.delay == 0.0: |
| 158 | + UPDATE_DELAY = args.delay |
| 159 | + if not args.switch: |
| 160 | + focus_watcher = FocusWatcher() |
| 161 | + focus_watcher.run() |
| 162 | + else: |
| 163 | + client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) |
| 164 | + client_socket.connect(SOCKET_FILE) |
| 165 | + client_socket.send(b'switch') |
| 166 | + client_socket.close() |
0 commit comments