==> bscratch.py <== """ Named scratchpad i3 module This is a module about ion3/notion-like named scratchpad implementation. You can think about it as floating "tabs" for windows, which can be shown/hidden by request, with next "tab" navigation. The foundation of it is a i3 mark function, you can create a mark with tag+'-'+uuid format. And then this imformation used to performs all actions. Also I've hacked fullscreen behaviour for it, so you can always get your scratchpad from fullscreen and also restore fullsreen state of the window when needed. """ import uuid from typing import List, Callable, Set, Optional import geom from cfg import cfg from matcher import Matcher from misc import Misc from negewmh import NegEWMH from negi3mod import negi3mod class bscratch(negi3mod, cfg, Matcher): """ Named scratchpad class Parents: cfg: configuration manager to autosave/autoload TOML-configutation with inotify Matcher: class to check that window can be tagged with given tag by WM_CLASS, WM_INSTANCE, regexes, etc """ def __init__(self, i3, loop=None) -> None: """ Init function Args: i3: i3ipc connection loop: asyncio loop. It's need to be given as parameter because of you need to bypass asyncio-loop to the thread """ # Initialize superclasses. cfg.__init__(self, i3, convert_me=True) Matcher.__init__(self) # Initialization # winlist is used to reduce calling i3.get_tree() too many times. self.win = None # fullscreen_list is used to perform fullscreen hacks self.fullscreen_list = [] # nsgeom used to respect current screen resolution in the geometry # settings and scale it self.nsgeom = geom.geom(self.cfg) # marked used to get the list of current tagged windows # with the given tag self.marked = {l: [] for l in self.cfg} # Mark all tags from the start self.mark_all_tags(hide=True) # Do not autosave geometry by default self.auto_save_geom(False) # focus_win_flag is a helper to perform attach/detach window to the # named scratchpad with add_prop/del_prop routines self.focus_win_flag = [False, ""] # i3ipc connection, bypassed by negi3mods runner self.i3ipc = i3 self.bindings = { "show": self.show_scratchpad, "hide": self.hide_scratchpad_all_but_current, "next": self.next_win_on_curr_tag, "toggle": self.toggle, "hide_current": self.hide_current, "geom_restore": self.geom_restore_current, "geom_dump": self.geom_dump_current, "geom_save": self.geom_save_current, "geom_autosave_mode": self.autosave_toggle, "subtag": self.run_subtag, "add_prop": self.add_prop, "del_prop": self.del_prop, "reload": self.reload_config, "dialog": self.dialog_toggle, } i3.on('window::new', self.mark_tag) i3.on('window::close', self.unmark_tag) def taglist(self) -> List: """ Returns list of tags without transients windows. """ tag_list = list(self.cfg.keys()) tag_list.remove('transients') return tag_list @staticmethod def mark_uuid_tag(tag: str) -> str: """ Generate unique mark for the given [tag] Args: tag: tag string """ return f'mark {tag}-{str(str(uuid.uuid4().fields[-1]))}' def show_scratchpad(self, tag: str, hide: bool = True) -> None: """ Show given [tag] Args: tag: tag string hide: optional predicate to hide all windows except current. Should be used in the most cases because of better performance and visual neatness """ win_to_focus = None for win in self.marked[tag]: win.command('move window to workspace current') win_to_focus = win if hide and tag != 'transients': self.hide_scratchpad_all_but_current(tag, win_to_focus) if win_to_focus is not None: win_to_focus.command('focus') def hide_scratchpad(self, tag: str) -> None: """ Hide given [tag] Args: tag (str): scratchpad name to hide """ if self.geom_auto_save: self.geom_save(tag) for win in self.marked[tag]: win.command('move scratchpad') self.restore_fullscreens() def hide_scratchpad_all_but_current(self, tag: str, current_win) -> None: """ Hide all tagged windows except current. Args: tag: tag string """ if len(self.marked[tag]) > 1 and current_win is not None: for win in self.marked[tag]: if win.id != current_win.id: win.command('move scratchpad') else: win.command('move window to workspace current') def find_visible_windows( self, focused: Optional[bool] = None) -> List: """ Find windows on the current workspace, which is enough for scratchpads. Args: focused: denotes that [focused] window should be extracted from i3.get_tree() or not """ if focused is None: focused = self.i3ipc.get_tree().find_focused() return NegEWMH.find_visible_windows( focused.workspace().leaves() ) def dialog_toggle(self) -> None: """ Show dialog windows """ self.show_scratchpad('transients', hide=False) def toggle_fs(self, win) -> None: """ Toggles fullscreen on/off and show/hide requested scratchpad after. Args: w : window that fullscreen state should be on/off. """ if win.fullscreen_mode: win.command('fullscreen toggle') self.fullscreen_list.append(win) def toggle(self, tag: str) -> None: """ Toggle scratchpad with given [tag]. Args: tag (str): denotes the target tag. """ if not self.marked.get(tag, []): prog_str = self.extract_prog_str(self.conf(tag)) if prog_str: self.i3ipc.command(f'exec {prog_str}') else: spawn_str = self.extract_prog_str( self.conf(tag), "spawn", exe_file=False ) if spawn_str: self.i3ipc.command( f'exec ~/.config/i3/send executor run {spawn_str}' ) if self.visible_window_with_tag(tag): self.hide_scratchpad(tag) return # We need to hide scratchpad it is visible, # regardless it focused or not focused = self.i3ipc.get_tree().find_focused() if self.marked.get(tag, []): self.toggle_fs(focused) self.show_scratchpad(tag) def focus_sub_tag(self, tag: str, subtag_classes_set: Set) -> None: """ Cycle over the subtag windows. Args: tag (str): denotes the target tag. subtag_classes_set (set): subset of classes of target [tag] which distinguish one subtag from another. """ focused = self.i3ipc.get_tree().find_focused() self.toggle_fs(focused) if focused.window_class in subtag_classes_set: return self.show_scratchpad(tag) for _ in self.marked[tag]: if focused.window_class not in subtag_classes_set: self.next_win_on_curr_tag() focused = self.i3ipc.get_tree().find_focused() def run_subtag(self, tag: str, subtag: str) -> None: """ Run-or-focus the application for subtag Args: tag (str): denotes the target tag. subtag (str): denotes the target subtag. """ if subtag in self.conf(tag): class_list = [win.window_class for win in self.marked[tag]] subtag_classes_set = self.conf(tag, subtag, "class") subtag_classes_matched = [ w for w in class_list if w in subtag_classes_set ] if not subtag_classes_matched: prog_str = self.extract_prog_str(self.conf(tag, subtag)) self.i3ipc.command(f'exec {prog_str}') self.focus_win_flag = [True, tag] else: self.focus_sub_tag(tag, subtag_classes_set) else: self.toggle(tag) def restore_fullscreens(self) -> None: """ Restore all fullscreen windows """ for win in self.fullscreen_list: win.command('fullscreen toggle') self.fullscreen_list = [] def visible_window_with_tag(self, tag: str) -> bool: """ Counts visible windows for given tag Args: tag (str): denotes the target tag. """ for win in self.find_visible_windows(): for i in self.marked[tag]: if win.id == i.id: return True return False def get_current_tag(self, focused) -> str: """ Get the current tag This function use focused window to determine the current tag. Args: focused : focused window. """ for tag in self.cfg: for i in self.marked[tag]: if focused.id == i.id: return tag return '' def apply_to_current_tag(self, func: Callable) -> bool: """ Apply function [func] to the current tag This is the generic function used in next_win_on_curr_tag, hide_current and another to perform actions on the currently selected tag. Args: func(Callable) : function to apply. """ curr_tag = self.get_current_tag(self.i3ipc.get_tree().find_focused()) if curr_tag: func(curr_tag) return bool(curr_tag) def next_win_on_curr_tag(self, hide: bool = True) -> None: """ Show the next window for the currently selected tag. Args: hide (bool): hide window or not. Primarly used to cleanup "garbage" that can appear after i3 (re)start, etc. Because of I've think that is't better to make screen clear after (re)start. """ def next_win(tag: str) -> None: self.show_scratchpad(tag, hide_) for idx, win in enumerate(self.marked[tag]): if focused_win.id != win.id: self.marked[tag][idx].command( 'move window to workspace current' ) self.marked[tag].insert( len(self.marked[tag]), self.marked[tag].pop(idx) ) win.command('move scratchpad') self.show_scratchpad(tag, hide_) hide_ = hide focused_win = self.i3ipc.get_tree().find_focused() self.apply_to_current_tag(next_win) def hide_current(self) -> None: """ Hide the currently selected tag. """ self.apply_to_current_tag(self.hide_scratchpad) def geom_restore(self, tag: str) -> None: """ Restore default window geometry Args: tag(str) : hide another windows for the current tag or not. """ for idx, win in enumerate(self.marked[tag]): # delete previous mark del self.marked[tag][idx] # then make a new mark and move scratchpad win_cmd = f"{bscratch.mark_uuid_tag(tag)}, \ move scratchpad, {self.nsgeom.get_geom(tag)}" win.command(win_cmd) self.marked[tag].append(win) def geom_restore_current(self) -> None: """ Restore geometry for the current selected tag. """ self.apply_to_current_tag(self.geom_restore) def geom_dump(self, tag: str) -> None: """ Dump geometry for the given tag Args: tag(str) : denotes target tag. """ focused = self.i3ipc.get_tree().find_focused() for win in self.marked[tag]: if win.id == focused.id: self.conf[tag]["geom"] = f"{focused.rect.width}x" + \ f"{focused.rect.height}+{focused.rect.x}+{focused.rect.y}" self.dump_config() break def geom_save(self, tag: str) -> None: """ Save geometry for the given tag Args: tag(str) : denotes target tag. """ focused = self.i3ipc.get_tree().find_focused() for win in self.marked[tag]: if win.id == focused.id: self.conf[tag]["geom"] = f"{focused.rect.width}x\ {focused.rect.height}+{focused.rect.x}+{focused.rect.y}" if win.rect.x != focused.rect.x \ or win.rect.y != focused.rect.y \ or win.rect.width != focused.rect.width \ or win.rect.height != focused.rect.height: self.nsgeom = geom.geom(self.cfg) win.rect.x = focused.rect.x win.rect.y = focused.rect.y win.rect.width = focused.rect.width win.rect.height = focused.rect.height break def auto_save_geom(self, save: bool = True, with_notification: bool = False) -> None: """ Set geometry autosave option with optional notification. Args: save(bool): predicate that shows that want to enable/disable autosave mode. with_notification(bool): to create notify-osd-based notification or not. """ self.geom_auto_save = save if with_notification: Misc.notify_msg(f"geometry autosave={save}") def autosave_toggle(self) -> None: """ Toggle autosave mode. """ if self.geom_auto_save: self.auto_save_geom(False, with_notification=True) else: self.auto_save_geom(True, with_notification=True) def geom_dump_current(self) -> None: """ Dump geometry for the current selected tag. """ self.apply_to_current_tag(self.geom_dump) def geom_save_current(self) -> None: """ Save geometry for the current selected tag. """ self.apply_to_current_tag(self.geom_save) def add_prop(self, tag_to_add: str, prop_str: str) -> None: """ Add property via [prop_str] to the target [tag]. Args: tag_to_add (str): denotes the target tag. prop_str (str): string in i3-match format used to add/delete target window in/from scratchpad. """ if tag_to_add in self.cfg: self.add_props(tag_to_add, prop_str) for tag in self.cfg: if tag != tag_to_add: self.del_props(tag, prop_str) if self.marked[tag] != []: for win in self.marked[tag]: win.command('unmark') self.initialize(self.i3ipc) def del_prop(self, tag: str, prop_str: str) -> None: """ Delete property via [prop_str] to the target [tag]. Args: tag (str): denotes the target tag. prop_str (str): string in i3-match format used to add/delete target window in/from scratchpad. """ self.del_props(tag, prop_str) def mark_tag(self, _, event) -> None: """ Add unique mark to the new window. Args: _: i3ipc connection. event: i3ipc event. We can extract window from it using event.container. """ win = event.container is_dialog_win = NegEWMH.is_dialog_win(win) self.win = win for tag in self.cfg: if not is_dialog_win and tag != "transients": if self.match(win, tag): # scratch_move win.command( f"{bscratch.mark_uuid_tag(tag)}, move scratchpad, \ {self.nsgeom.get_geom(tag)}") self.marked[tag].append(win) elif is_dialog_win and tag == "transients": win.command( f"{bscratch.mark_uuid_tag('transients')}, \ move scratchpad") self.marked["transients"].append(win) # Special hack to invalidate windows after subtag start if self.focus_win_flag[0]: special_tag = self.focus_win_flag[1] if special_tag in self.cfg: self.show_scratchpad(special_tag, hide=True) self.focus_win_flag[0] = False self.focus_win_flag[1] = "" self.dialog_toggle() def unmark_tag(self, _, event) -> None: """ Delete unique mark from the closed window. Args: _: i3ipc connection. event: i3ipc event. We can extract window from it using event.container. """ win_ev = event.container self.win = win_ev for tag in self.taglist(): for win in self.marked[tag]: if win.id == win_ev.id: self.marked[tag].remove(win) self.show_scratchpad(tag) break if win_ev.fullscreen_mode: self.apply_to_current_tag(self.hide_scratchpad) for transient in self.marked["transients"]: if transient.id == win_ev.id: self.marked["transients"].remove(transient) def mark_all_tags(self, hide: bool = True) -> None: """ Add marks to the all tags. Args: hide (bool): hide window or not. Primarly used to cleanup "garbage" that can appear after i3 (re)start, etc. Because of I've think that is't better to make screen clear after (re)start. """ winlist = self.i3ipc.get_tree().leaves() hide_cmd = '' for win in winlist: is_dialog_win = NegEWMH.is_dialog_win(win) for tag in self.cfg: if not is_dialog_win and tag != "transients": if self.match(win, tag): if hide: hide_cmd = '[con_id=__focused__] scratchpad show' win_cmd = f"{bscratch.mark_uuid_tag(tag)}, \ move scratchpad, \ {self.nsgeom.get_geom(tag)}, {hide_cmd}" win.command(win_cmd) self.marked[tag].append(win) if is_dialog_win: win_cmd = f"{bscratch.mark_uuid_tag('transients')}, \ move scratchpad" win.command(win_cmd) self.marked["transients"].append(win) self.win = win ==> cfg.py <== """ Dynamic TOML-based config for negi3mods. This is a superclass for negi3mods which want to store configuration via TOML files. It supports inotify-based updating of self.cfg dynamically and has pretty simple API. I've considered that inheritance here is good idea. """ import re import os import sys from typing import Set, Callable import traceback import toml from misc import Misc class cfg(object): def __init__(self, i3, convert_me: bool = False, loop=None) -> None: # detect current negi3mod self.mod = self.__class__.__name__ # negi3mod config path self.i3_cfg_mod_path = Misc.i3path() + '/cfg/' + self.mod + '.cfg' # convert config values or not self.convert_me = convert_me # load current config self.load_config() # used for props add / del hacks self.win_attrs = {} # bind numbers to cfg names self.conv_props = { 'class': 'class', 'instance': 'instance', 'window_role': 'window_role', 'title': 'name', } self.i3ipc = i3 self.loop = None if loop is not None: self.loop = loop def conf(self, *conf_path): """ Helper to extract config for current tag. Args: conf_path: path of config from where extract. """ ret = {} for part in conf_path: if not ret: ret = self.cfg.get(part) else: ret = ret.get(part) return ret @staticmethod def extract_prog_str(conf_part: str, prog_field: str = "prog", exe_file: bool = True): """ Helper to extract prog(by default) string from config Args: conf_part (str): part of config from where you want to extract it. prog_field (str): string name to extract. """ if conf_part is None: return "" if exe_file: return re.sub( "~", os.path.realpath(os.path.expandvars("$HOME")), conf_part.get(prog_field, "") ) return conf_part.get(prog_field, "") @staticmethod def cfg_regex_props() -> Set[str]: """ Props with regexes """ # regex cfg properties return {"class_r", "instance_r", "name_r", "role_r"} def win_all_props(self): """ All window props """ # basic + regex props return self.cfg_props() | self.cfg_regex_props() @staticmethod def possible_props() -> Set[str]: """ Possible window props """ # windows properties used for props add / del return {'class', 'instance', 'window_role', 'title'} @staticmethod def cfg_props() -> Set[str]: """ basic window props """ # basic cfg properties, without regexes return {'class', 'instance', 'name', 'role'} @staticmethod def subtag_attr_list() -> Set[str]: """ Helper to create subtag attr list. """ return cfg.possible_props() def reload_config(self, *arg) -> None: """ Reload config for current selected module. Call load_config, print debug messages and reinit all stuff. """ prev_conf = self.cfg try: self.load_config() if self.loop is None: self.__init__(self.i3ipc) else: self.__init__(self.i3ipc, loop=self.loop) print(f"[{self.mod}] config reloaded") except Exception: print(f"[{self.mod}] config reload failed") traceback.print_exc(file=sys.stdout) self.cfg = prev_conf self.__init__() def dict_converse(self) -> None: """ Convert list attributes to set for the better performance. """ self.dict_apply(lambda key: set(key), cfg.convert_subtag) def dict_deconverse(self) -> None: """ Convert set attributes to list, because of set cannot be saved / restored to / from TOML-files corretly. """ self.dict_apply(lambda key: list(key), cfg.deconvert_subtag) @staticmethod def convert_subtag(subtag: str) -> None: """ Convert subtag attributes to set for the better performance. Args: subtag (str): target subtag. """ cfg.subtag_apply(subtag, lambda key: set(key)) @staticmethod def deconvert_subtag(subtag: str) -> None: """ Convert set attributes to list, because of set cannot be saved / restored to / from TOML-files corretly. Args: subtag (str): target subtag. """ cfg.subtag_apply(subtag, lambda key: list(key)) def dict_apply(self, field_conv: Callable, subtag_conv: Callable) -> None: """ Convert list attributes to set for the better performance. Args: field_conv (Callable): function to convert dict field. subtag_conv (Callable): function to convert subtag inside dict. """ for string in self.cfg.values(): for key in string: if key in self.win_all_props(): string[sys.intern(key)] = field_conv( string[sys.intern(key)] ) elif key == "subtag": subtag_conv(string[sys.intern(key)]) @staticmethod def subtag_apply(subtag: str, field_conv: Callable) -> None: """ Convert subtag attributes to set for the better performance. Args: subtag (str): target subtag name. field_conv (Callable): function to convert dict field. """ for val in subtag.values(): for key in val: if key in cfg.subtag_attr_list(): val[sys.intern(key)] = field_conv(val[sys.intern(key)]) def load_config(self) -> None: """ Reload config itself and convert lists in it to sets for the better performance. """ with open(self.i3_cfg_mod_path, "r") as negi3modcfg: self.cfg = toml.load(negi3modcfg) if self.convert_me: self.dict_converse() def dump_config(self) -> None: """ Dump current config, can be used for debugging. """ with open(self.i3_cfg_mod_path, "r+") as negi3modcfg: if self.convert_me: self.dict_deconverse() toml.dump(self.cfg, negi3modcfg) self.cfg = toml.load(negi3modcfg) if self.convert_me: self.dict_converse() def property_to_winattrib(self, prop_str: str) -> None: """ Parse property string to create win_attrs dict. Args: prop_str (str): property string in special format. """ self.win_attrs = {} prop_str = prop_str[1:-1] for token in prop_str.split('@'): if token: toks = token.split('=') attr = toks[0] value = toks[1] if value[0] == value[-1] and value[0] in {'"', "'"}: value = value[1:-1] if attr in cfg.subtag_attr_list(): self.win_attrs[self.conv_props.get(attr, {})] = value def add_props(self, tag: str, prop_str: str) -> None: """ Move window to some tag. Args: tag (str): target tag prop_str (str): property string in special format. """ self.property_to_winattrib(prop_str) ftors = self.cfg_props() & set(self.win_attrs.keys()) if tag in self.cfg: for tok in ftors: if self.win_attrs[tok] not in \ self.cfg.get(tag, {}).get(tok, {}): if tok in self.cfg[tag]: if isinstance(self.cfg[tag][tok], str): self.cfg[tag][tok] = {self.win_attrs[tok]} elif isinstance(self.cfg[tag][tok], set): self.cfg[tag][tok].add(self.win_attrs[tok]) else: self.cfg[tag].update({tok: self.win_attrs[tok]}) # special fix for the case where attr # is just attr not {attr} if isinstance(self.conf(tag, tok), str): self.cfg[tag][tok] = {self.win_attrs[tok]} def del_direct_props(self, target_tag: str) -> None: """ Remove basic(non-regex) properties of window from target tag. Args: tag (str): target tag """ # Delete 'direct' props: for prop in self.cfg[target_tag].copy(): if prop in self.cfg_props(): if isinstance(self.conf(target_tag, prop), str): del self.cfg[target_tag][prop] elif isinstance(self.conf(target_tag, prop), set): for tok in self.cfg[target_tag][prop].copy(): if self.win_attrs[prop] == tok: self.cfg[target_tag][prop].remove(tok) def del_regex_props(self, target_tag: str) -> None: """ Remove regex properties of window from target tag. Args: target_tag (str): target tag """ def check_for_win_attrs(win, prop): class_r_check = \ (prop == "class_r" and winattr == win.window_class) instance_r_check = \ (prop == "instance_r" and winattr == win.window_instance) role_r_check = \ (prop == "role_r" and winattr == win.window_role) if class_r_check or instance_r_check or role_r_check: self.cfg[target_tag][prop].remove(target_tag) # Delete appropriate regexes for prop in self.cfg[target_tag].copy(): if prop in self.cfg_regex_props(): for reg in self.cfg[target_tag][prop].copy(): if prop == "class_r": lst_by_reg = self.i3ipc.get_tree().find_classed(reg) if prop == "instance_r": lst_by_reg = self.i3ipc.get_tree().find_instanced(reg) if prop == "role_r": lst_by_reg = self.i3ipc.get_tree().find_by_role(reg) winattr = self.win_attrs[prop[:-2]] for win in lst_by_reg: check_for_win_attrs(win, prop) def del_props(self, tag: str, prop_str: str) -> None: """ Remove window from some tag. Args: tag (str): target tag prop_str (str): property string in special format. """ self.property_to_winattrib(prop_str) self.del_direct_props(tag) self.del_regex_props(tag) # Cleanup for prop in self.cfg_regex_props() | self.cfg_props(): if prop in self.conf(tag) and self.conf(tag, prop) == set(): del self.cfg[tag][prop] ==> circle.py <== """ Circle over windows module. This is a module about better run-or-raise features like in ion3, stumpwm and others. As the result user can get not only the usual run the appropriate application if it is not started, but also create a list of application, which I call "tag" and then switch to the next of it, instead of just simple focus. The foundation of it is pretty complicated go_next function, which use counters with incrementing of the current "position" of the window in the tag list over the finite field. As the result you get circle over all tagged windows. Also I've hacked fullscreen behaviour for it, so you can always switch to the window with the correct fullscreen state, where normal i3 behaviour has a lot of issues here in detection of existing/visible windows, etc. """ from negi3mod import negi3mod from matcher import Matcher from cfg import cfg class circle(negi3mod, cfg, Matcher): """ Circle over windows class Parents: cfg: configuration manager to autosave/autoload TOML-configutation with inotify Matcher: class to check that window can be tagged with given tag by WM_CLASS, WM_INSTANCE, regexes, etc """ def __init__(self, i3, loop=None) -> None: """ Init function Main part is in self.initialize, which performs initialization itself. Args: i3: i3ipc connection loop: asyncio loop. It's need to be given as parameter because of you need to bypass asyncio-loop to the thread """ # Initialize superclasses. cfg.__init__(self, i3, convert_me=True) Matcher.__init__(self) # i3ipc connection, bypassed by negi3mods runner. self.i3ipc = i3 # map of tag to the tagged windows. self.tagged = {} # current_position for the tag [tag] self.current_position = {} # list of windows which fullscreen state need to be restored. self.restore_fullscreen = [] # is the current action caused by user actions or not? It's needed for # corrent fullscreen on/off behaviour. self.interactive = True # how many attempts taken to find window with priority self.repeats = 0 # win cache for the fast matching self.win = None # used for subtag info caching self.subtag_info = {} # Should the special fullscreen-related actions to be performed or not. self.need_handle_fullscreen = True # Initialize i3tree = self.i3ipc.get_tree() # prepare for prefullscreen self.fullscreened = i3tree.find_fullscreen() # store the current window here to cache get_tree().find_focused value. self.current_win = i3tree.find_focused() # winlist is used to reduce calling i3.get_tree() too many times. self.winlist = i3tree.leaves() for tag in self.cfg: self.tagged[tag] = [] self.current_position[tag] = 0 # tag all windows after start self.tag_windows(invalidate_winlist=False) self.bindings = { "next": self.go_next, "subtag": self.go_subtag, "add_prop": self.add_prop, "del_prop": self.del_prop, "reload": self.reload_config, } self.i3ipc.on('window::new', self.add_wins) self.i3ipc.on('window::close', self.del_wins) self.i3ipc.on("window::focus", self.set_curr_win) self.i3ipc.on("window::fullscreen_mode", self.handle_fullscreen) def run_prog(self, tag: str, subtag: str = '') -> None: """ Run the appropriate application for the current tag/subtag. Args: tag (str): denotes target [tag] subtag (str): denotes the target [subtag], optional. """ if tag is not None and self.cfg.get(tag) is not None: if not subtag: prog_str = self.extract_prog_str(self.conf(tag)) else: prog_str = self.extract_prog_str( self.conf(tag, subtag) ) if prog_str: self.i3ipc.command(f'exec {prog_str}') else: spawn_str = self.extract_prog_str( self.conf(tag), "spawn", exe_file=False ) if spawn_str: self.i3ipc.command( f'exec ~/.config/i3/send executor run {spawn_str}' ) def find_next_not_the_same_win(self, tag: str) -> None: """ It was used as the guard to infinite loop in the past. Args: tag (str): denotes target [tag] """ if len(self.tagged[tag]) > 1: self.current_position[tag] += 1 self.go_next(tag) def prefullscreen(self, tag: str) -> None: """ Prepare to go fullscreen. """ for win in self.fullscreened: if self.current_win.window_class in set(self.conf(tag, "class")) \ and self.current_win.id == win.id: self.need_handle_fullscreen = False win.command('fullscreen disable') def postfullscreen(self, tag: str, idx: int) -> None: """ Exit from fullscreen. """ now_focused = self.twin(tag, idx).id for win_id in self.restore_fullscreen: if win_id == now_focused: self.need_handle_fullscreen = False self.i3ipc.command( f'[con_id={now_focused}] fullscreen enable' ) def focus_next(self, tag: str, idx: int, inc_counter: bool = True, fullscreen_handler: bool = True, subtagged: bool = False) -> None: """ Focus next window. Used by go_next function. Tag list is a list of windows by some factor, which determined by config settings. Args: tag (str): target tag. idx (int): index inside tag list. inc_counter (bool): increase counter or not. fullscreen_handler (bool): for manual set / unset fullscreen, because of i3 is not perfect in it. For example you need it for different workspaces. subtagged (bool): this flag denotes to subtag using. """ if fullscreen_handler: self.prefullscreen(tag) self.twin(tag, idx, subtagged).command('focus') if inc_counter: self.current_position[tag] += 1 if fullscreen_handler: self.postfullscreen(tag, idx) self.need_handle_fullscreen = True def twin(self, tag: str, idx: int, with_subtag: bool = False): """ Detect target window. Args: tag (str): selected tag. idx (int): index in tag list. with_subtag (bool): contains subtag, special behaviour then. """ if not with_subtag: return self.tagged[tag][idx] subtag_win_classes = self.subtag_info.get("class", {}) for subidx, win in enumerate(self.tagged[tag]): if win.window_class in subtag_win_classes: return self.tagged[tag][subidx] return self.tagged[tag][0] def need_priority_check(self, tag): """ Checks that priority string is defined, then thecks that currrent window not in class set. Args: tag(str): target tag name """ return "priority" in self.conf(tag) and \ self.current_win.window_class not in set(self.conf(tag, "class")) def not_priority_win_class(self, tag, win): """ Window class is not priority class for the given tag Args: tag(str): target tag name win: window """ return win.window_class in self.conf(tag, "class") and \ win.window_class != self.conf(tag, "priority") def no_prioritized_wins(self, tag): """ Checks all tagged windows for the priority win. Args: tag(str): target tag name """ return not [ win for win in self.tagged[tag] if win.window_class == self.conf(tag, "priority") ] def go_next(self, tag: str) -> None: """ Circle over windows. Function "called" from the user-side. Args: tag (str): denotes target [tag] """ self.sort_by_parent(tag) if not self.tagged[tag]: self.run_prog(tag) elif len(self.tagged[tag]) == 1: idx = 0 self.focus_next(tag, idx, fullscreen_handler=False) else: idx = self.current_position[tag] % len(self.tagged[tag]) if self.need_priority_check(tag): for win in self.tagged[tag]: if self.no_prioritized_wins(tag): self.run_prog(tag) return for idx, win in enumerate(self.tagged[tag]): if win.window_class == self.conf(tag, "priority"): self.focus_next(tag, idx, inc_counter=False) elif self.current_win.id == self.twin(tag, idx).id: self.find_next_not_the_same_win(tag) else: self.focus_next(tag, idx) def go_subtag(self, tag: str, subtag: str) -> None: """ Circle over subtag windows. Function "called" from the user-side. Args: tag (str): denotes target [tag] subtag (str): denotes the target [subtag]. """ self.subtag_info = self.conf(tag, subtag) self.tag_windows() if self.subtag_info: subtagged_class_set = set(self.subtag_info.get("class", {})) tagged_win_classes = { w.window_class for w in self.tagged.get(tag, {}) } if not tagged_win_classes & subtagged_class_set: self.run_prog(tag, subtag) else: idx = 0 self.focus_next(tag, idx, subtagged=True) def add_prop(self, tag_to_add: str, prop_str: str) -> None: """ Add property via [prop_str] to the target [tag]. Args: tag (str): denotes the target tag. prop_str (str): string in i3-match format used to add/delete target window in/from scratchpad. """ if tag_to_add in self.cfg: self.add_props(tag_to_add, prop_str) for tag in self.cfg: if tag != tag_to_add: self.del_props(tag, prop_str) self.initialize(self.i3ipc) def del_prop(self, tag: str, prop_str: str) -> None: """ Delete property via [prop_str] to the target [tag]. Args: tag (str): denotes the target tag. prop_str (str): string in i3-match format used to add/delete target window in/from scratchpad. """ self.del_props(tag, prop_str) def find_acceptable_windows(self, tag: str) -> None: """ Wrapper over Matcher.match to find acceptable windows and add it to tagged[tag] list. Args: tag (str): denotes the target tag. """ for win in self.winlist: if self.match(win, tag): self.tagged.get(tag, {}).append(win) def tag_windows(self, invalidate_winlist=True) -> None: """ Find acceptable windows for the all tags and add it to the tagged[tag] list. Args: tag (str): denotes the target tag. """ if invalidate_winlist: self.winlist = self.i3ipc.get_tree().leaves() self.tagged = {} for tag in self.cfg: self.tagged[tag] = [] self.find_acceptable_windows(tag) def sort_by_parent(self, tag: str) -> None: """ Sort windows by some infernal logic: At first sort by parent container order, than in any order. Args: tag (str): target tag to sort. """ i = 0 try: for tagged_win in self.tagged[tag]: for container_win in tagged_win.parent: if container_win in self.tagged[tag]: oldidx = self.tagged[tag].index(container_win) self.tagged[tag].insert( i, self.tagged[tag].pop(oldidx) ) i += 1 except TypeError: pass def add_wins(self, _, event) -> None: """ Tag window if it is match defined rules. Args: _: i3ipc connection. event: i3ipc event. We can extract window from it using event.container. """ win = event.container for tag in self.cfg: if self.match(win, tag): self.tagged[tag].append(win) self.win = win def del_wins(self, _, event) -> None: """ Delete tag from window if it's closed. Args: _: i3ipc connection. event: i3ipc event. We can extract window from it using event.container. """ win_con = event.container for tag in self.cfg: if self.match(win_con, tag): for win in self.tagged[tag]: if win.id in self.restore_fullscreen: self.restore_fullscreen.remove(win.id) for tag in self.cfg: for win in self.tagged[tag]: if win.id == win_con.id: self.tagged[tag].remove(win) self.subtag_info = {} def set_curr_win(self, _, event) -> None: """ Cache the current window. Args: _: i3ipc connection. event: i3ipc event. We can extract window from it using event.container. """ self.current_win = event.container def handle_fullscreen(self, _, event) -> None: """ Performs actions over the restore_fullscreen list. This function memorize the current state of the fullscreen property of windows for the future reuse it in functions which need to set/unset fullscreen state of the window correctly. Args: _: i3ipc connection. event: i3ipc event. We can extract window from it using event.container. """ win = event.container self.fullscreened = self.i3ipc.get_tree().find_fullscreen() if self.need_handle_fullscreen: if win.fullscreen_mode: if win.id not in self.restore_fullscreen: self.restore_fullscreen.append(win.id) return if not win.fullscreen_mode: if win.id in self.restore_fullscreen: self.restore_fullscreen.remove(win.id) return