# 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()