# A script to generate keybindings for i3 Window Manager modes with consistent behavior and help text. from __future__ import annotations import io from contextlib import redirect_stdout from dataclasses import dataclass, field from pathlib import Path from textwrap import indent from typing import List, Optional, Dict, Tuple @dataclass class Binding: """An abstract superclass for both mode and command bindings.""" name: str key: str = field(default="") # Execute the binding only on key release release: bool = field(default=False) # A hidden binding won't show up in mode descriptions. Useful if you want to have # a second version of a binding with a modifier key, but don't need it to show up separately.""" hidden: bool = field(default=False) # Override the binding's default hint text. hint: str = field(init=False, default="") def __post_init__(self): if not self.key: self.key = self.name.lower()[0] def binding(self, in_mode: Optional[str] = None) -> str: pass def _bind_prefix(self, key: Optional[str] = None) -> str: prefix = "bindsym " if self.release: prefix += "--release " prefix += key or self.key return prefix def mode_hint(self) -> str: """How to display this binding in the help text of a Mode.""" if self.hint: return self.hint if len(self.key) == 1 and self.key in self.name: return self.name.replace(self.key, f"{self.key}", 1) return f"[{self.key}]{self.name}" @dataclass class Mode(Binding): """A mode which can contain a list of sub-bindings, which may be a mix of commands and modes. exit_bindings contains a list of binds that will cancel the mode and return to default.""" bindings: List[Binding] = field(default_factory=list) exit_bindings = [ "Return", "Escape", "control+g", "control+bracketleft", ] def __post_init__(self): super().__post_init__() used_keys: Dict[str, Binding] = {} for binding in self.bindings: # Deconflicting directional bindings is complicated, so let's punt on it for now if isinstance(binding, Directional): continue err_base = ( f"Binding {binding.key} for {self.name}.{binding.name} conflicts with " ) if binding.key in self.exit_bindings: raise ValueError(err_base + "mode escape key.") if binding.key in used_keys: raise ValueError(err_base + used_keys[binding.key].name) used_keys[binding.key] = binding def mode_name(self, parent: Optional[str]) -> str: """The full name of the mode including its parents names, for disambiguation.""" if parent: return f"{parent}_{self.name}" else: return self.name def mode_var(self, parent: Optional[str]) -> str: """The name of the variable representing the mode.""" return f"$mode_{self.mode_name(parent)}" def mode_label(self, parent: Optional[str]) -> str: """The mode label to show at the start of the mode help text.""" lineage = self.mode_name(parent).split("_") # Remove the common [space] prefix on nested modes. if len(lineage) > 1: lineage.remove("space") return "".join(f"[{part}]" for part in lineage) def mode_def(self, parent: Optional[str]) -> str: name = self.mode_name(parent) var = self.mode_var(parent) binding_names = ", ".join( [binding.mode_hint() for binding in self.bindings if not binding.hidden] ) help_text = f"{self.mode_label(parent)}: {binding_names}" mode = f"set {var} {help_text}\n" f'mode --pango_markup "{var}" {{\n' for binding in self.bindings: mode += indent(binding.binding(name) + "\n", " ") for binding in self.exit_bindings: mode += indent(f'{self._bind_prefix(binding)} mode "default"\n', " ") mode += "}\n" submodes = [binding for binding in self.bindings if isinstance(binding, Mode)] for sub in submodes: mode += "\n" mode += sub.mode_def(name) return mode def binding(self, parent: Optional[str] = None) -> str: return f'{self._bind_prefix()} mode "{self.mode_var(parent)}"' @dataclass class Command(Binding): command: Optional[str] = field(default=None) exit_mode: bool = field(default=True) def __post_init__(self): super().__post_init__() if not self.command: self.command = self.name.lower() def binding(self, parent: Optional[str] = None) -> str: return self._binding(self.key, self.command, parent) def _binding(self, key: str, command: str, parent: Optional[str] = None) -> str: bind = f"{self._bind_prefix(key)} {command}" if parent and self.exit_mode: bind += ', mode "default"' return bind @dataclass class Exec(Command): startup_id: bool = field(default=False) def binding(self, parent: Optional[str] = None) -> str: return self._binding( self.key, f"exec {'--no-startup-id ' if not self.startup_id else ''}{self.command}", parent, ) @dataclass class Directional(Command): """A convenience class for directional commands that can be executed upleft/down/up/right with h/j/k/l or the arrow keys. Optional parameters include a subset of directions to use, and a modifier for the command. The command should include the template variable {direction}.""" subset: Optional[List[str]] = None modifier: Optional[str] = None flip: bool = False flips = {"left": "right", "right": "left", "down": "up", "up": "down"} directions = { "left": ["h", "Left"], "down": ["j", "Down"], "up": ["k", "Up"], "right": ["l", "Right"], } def prefix(self) -> str: return self.modifier + "+" if self.modifier else "" def used_keys(self) -> Tuple[List[str], List[str]]: letter_keys = [] arrow_keys = [] for direction in self.directions: if not self.subset or direction in self.subset: letter, arrow = self.directions[direction] letter_keys.append(letter) arrow_keys.append(arrow) return letter_keys, arrow_keys def mode_hint(self) -> str: letter_keys, arrow_keys = self.used_keys() hint = self.prefix() + "" hint += "/".join(letter_keys) hint += "/" hint += "/".join(arrow_keys) hint += f" {self.name}" return hint def binding(self, parent: Optional[str] = None): bind = "" for direction, keys in self.directions.items(): if not self.subset or direction in self.subset: if self.flip: direction = self.flips[direction] for key in keys: bind += self._binding(self.prefix() + key, self.command).format( direction=direction ) bind += "\n" return bind @dataclass class App: """ Required args: name: The name of the app. Accepted args: key: The key to use. Defaults to the first lower case letter of the name. Regular press will try to switch to the app if it's already open. Shift press will guarantee opening a new instance. Shift bindings are hidden from mode help text. path: The binary to execute. Defaults to the lowercase version of the name. args: Args to pass to the app on launch class: The X11 Window Class to search for. Defaults to the app name. Case insensitive. title: The X11 Window Title to search for. switch: If True, switch to the app instead of launching a new instance unless shift is held. Default True. """ name: str key: str = field(default="") path: str = field(default="") args: str = field(default="") window_class: str = field(default="") window_title: str = field(default="") switch: bool = field(default=True) def __post_init__(self): if not self.key: self.key = self.name.lower()[0] if not self.path: self.path = self.name.lower() if not self.window_class: self.window_class = self.name @property def class_query(self) -> str: return f'class="(?i){self.window_class}"' @property def title_query(self) -> str: if self.window_title: return f' title="(?i){self.window_title}"' else: return "" @property def query(self) -> str: return f"[{self.class_query}{self.title_query}]" def commands(self) -> List[Command]: cmd = f"{self.path} {self.args}" switch_cmd = f"pgrep {self.path} && i3-msg '{self.query} focus' || {cmd}" commands = [ Exec(self.name, key=self.key, command=switch_cmd if self.switch else cmd), Exec(self.name, key=f"Shift+{self.key}", command=cmd, hidden=True), ] return commands @staticmethod def find(name: str) -> Optional[App]: for app in apps: if app.name == name: return app return None apps = [ App("firefox"), App("kitty", key="t", args="--single-instance", switch=False), App("emacs"), App("pycharm"), App("idea", key="j"), App("discord"), App("slack"), App("zoom"), App( "windows", path="virt-viewer", args="--connect qemu:///system -w Windows10", window_class="virt-viewer", ), ] bindings = [ Exec("Terminal", key="$mod+Return", command="kitty --single-instance"), Command("next workspace", key="$mod+Tab", command="workspace next_on_output"), Command("prev workspace", key="$mod+Shift+Tab", command="workspace prev_on_output"), # Volume control Exec( "Volume Up", key="XF86AudioRaiseVolume", command="pactl set-sink-volume @DEFAULT_SINK@ +10%", startup_id=False, ), Exec( "Volume Down", key="XF86AudioLowerVolume", command="pactl set-sink-volume @DEFAULT_SINK@ -10%", startup_id=False, ), Exec( "Mute", key="XF86AudioMute", command="pactl set-sink-mute @DEFAULT_SINK@ toggle", startup_id=False, ), Directional("Focus", command="focus {direction}", modifier="$mod"), Directional( "Move", command="move {direction}", modifier="$mod+Shift", release=True ), Mode( "space", key="$mod+space", bindings=[ Exec("Run", key="space", command="rofi -show run"), Mode( "workspace", key="p", bindings=[ Mode( "move", bindings=[ Directional( "Move", command="exec --no-startup-id i3-msg move workspace to output {direction}", subset=["left", "right"], exit_mode=False, ), ], ), Exec("switch to", key="p", command="i3_switch_workspace.sh"), ], ), Mode( "window", bindings=[ Command("delete", command="kill"), Command("fullscreen", command="fullscreen toggle"), Command("float", key="o", command="floating toggle"), Command("split", command="split h"), Command("vertical split", key="v", command="split v"), Command("parent", command="focus parent", exit_mode=False), Mode( "move", bindings=[ Directional( "Move", command="move {direction}", exit_mode=False, release=True, ), Exec( "to workspace", key="p", command="i3_send_to_workspace.sh", ), ], ), Mode( "resize", bindings=[ Directional( "Grow", command="resize grow {direction} 10 px or 10 ppt", exit_mode=False, ), Directional( "Shrink", command="resize shrink {direction} 10 px or 10 ppt", modifier="Shift", exit_mode=False, flip=True, ), ], ), Mode( "layout", bindings=[ Command("split", command="toggle split"), Command("tabbed", command="layout tabbed"), Command("stacking", key="k", command="layout stacking"), ], ), ], ), Mode("open", bindings=[cmd for app in apps for cmd in app.commands()]), Mode( "goto", bindings=[ Exec("server", command="rofi -show ssh"), ], ), Mode( "quit", bindings=[ Exec("logout", key="q", command="i3-msg exit"), Exec("reload", command="i3_reconfigure", key="r"), Command("restart", key="Shift+r"), Mode( "system", bindings=[ Exec( "suspend", startup_id=False, command="systemctl suspend" ), Exec( "reboot", startup_id=False, command="systemctl reboot" ), Exec( "power off", startup_id=False, command="systemctl poweroff", ), ], ), ], ), ], ), ] @dataclass class Output: name: str pos: str mode: str = field(default="2560x1440") rate: int = field(default=60) primary: bool = field(default=False) def xrandr_flags(self) -> str: flags = f"--output {self.name} --pos {self.pos} --mode {self.mode} --rate {self.rate}" if self.primary: flags += " --primary" return flags outputs = [ Output("DP-0", "0x0"), Output("DP-4", "2560x0", rate=144, primary=True), Output("HDMI-0", "5120x0"), ] def xrandr_command() -> str: cmd = "exec --no-startup-id xrandr\\\n" for output in outputs: cmd += indent(output.xrandr_flags() + " \\\n", " ") cmd = cmd[:-3] return cmd @dataclass class Workspace: name: str output: str = field(default="primary") assigned_apps: List[str] = field(default_factory=list) def config(self, index: int) -> str: ws_var = f"$ws_{self.name.lower()}" cfg = f"set {ws_var} {index}: {self.name}\n" cfg += f'workspace "{ws_var}" output {self.output}\n' for a in self.assigned_apps: app = App.find(a) if not app: raise ValueError(f"Couldn't find app {a}") cfg += f'assign {app.query} "{ws_var}"\n' return cfg workspaces = [ Workspace("Main", output="DP-4"), Workspace("Tasks", output="DP-0", assigned_apps=["windows"]), Workspace("Comms", output="HDMI-0", assigned_apps=["discord", "slack"]), Workspace("Python", output="primary", assigned_apps=["pycharm"]), Workspace("Java", output="primary", assigned_apps=["idea"]), ] def workspace_config() -> str: f = io.StringIO() with redirect_stdout(f): for idx, workspace in enumerate(workspaces): print(workspace.config(idx + 1)) return f.getvalue() def binds_config() -> str: f = io.StringIO() preamble = (Path(__file__).parent / "preamble").read_text() postscript = (Path(__file__).parent / "postscript").read_text() with redirect_stdout(f): print(preamble) print() print(xrandr_command()) print() for binding in bindings: if isinstance(binding, Mode): print(binding.mode_def(None)) print(binding.binding()) print() print(workspace_config(), end="") print(postscript) return f.getvalue() def main(): print(binds_config()) if __name__ == "__main__": main()