0
0

yabai.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. #!/usr/bin/env python3
  2. from json import loads
  3. from logging import NOTSET, basicConfig, debug, info
  4. from os import getenv
  5. from subprocess import CalledProcessError, check_output
  6. from sys import argv
  7. from typing import TypeAlias
  8. HOME = getenv("HOME")
  9. XDG_CONFIG_HOME = getenv("XDG_CONFIG_HOME")
  10. TARGET_DISPLAY_WIDTH = 1680
  11. TARGET_DISPLAY_HEIGHT = 1050
  12. class Window:
  13. def __init__(self, data: dict):
  14. self.id: int = data["id"]
  15. self.app: str = data["app"]
  16. self.space: str = data["space"]
  17. self.title: str = data["title"]
  18. self.has_focus: bool = data["has-focus"]
  19. self.frame_w: int = data["frame"]["w"]
  20. self.frame_h: int = data["frame"]["h"]
  21. @property
  22. def size(self) -> int:
  23. return self.frame_w * self.frame_h
  24. def __repr__(self) -> str:
  25. return (
  26. f"Window({self.title} | {self.app}"
  27. f", {self.id}"
  28. f"{', Focused' if self.has_focus else ''})"
  29. )
  30. def __gt__(self, other: "Window"):
  31. return self.id.__gt__(other.id)
  32. class Space:
  33. def __init__(self, data: dict):
  34. self.id: int = data["id"]
  35. self.index: int = data["index"]
  36. self.label: str = data["label"]
  37. self.windows: list[int] = data["label"]
  38. self.has_focus: bool = data["has-focus"]
  39. self.is_native_fullscreen: bool = data["is-native-fullscreen"]
  40. def __repr__(self) -> str:
  41. return (
  42. f"Space({self.label if self.label and len(self.label) > 0 else '<NoLabel>'}"
  43. f", {self.index}"
  44. f"{', Fullscreen' if self.is_native_fullscreen else ''}"
  45. f"{', Focused' if self.has_focus else ''})"
  46. )
  47. def __gt__(self, other: "Space"):
  48. return self.index.__gt__(other.index)
  49. class Display:
  50. def __init__(self, data: dict):
  51. self.id: int = data["id"]
  52. self.uuid: str = data["uuid"]
  53. self.index: int = data["index"]
  54. self.label: str = data["label"]
  55. self.has_focus: bool = data["has-focus"]
  56. self.spaces: list[str] = data["spaces"]
  57. self.frame: dict = data["frame"]
  58. def __repr__(self) -> str:
  59. return (
  60. f"Space({self.label if self.label and len(self.label) > 0 else '<NoLabel>'}"
  61. f", {self.index}"
  62. f", Frame ({self.frame})"
  63. f"{', Focused' if self.has_focus else ''})"
  64. )
  65. def __gt__(self, other: "Display"):
  66. return self.frame["w"].__gt__(other.frame["w"])
  67. SpaceSel: TypeAlias = int | str
  68. class Yabai:
  69. spaces: list[tuple[str, bool, list[str]]] = [
  70. ("Desktop", False, ["*"]),
  71. ("Finder", False, ["Finder"]),
  72. ("Terminal", True, ["Alacritty"]),
  73. ("Browser", True, ["Firefox"]),
  74. ("Communication", False, ["Slack", "Signal", "Spotify", "Notion"]),
  75. ("Notetaking", True, ["Obsidian", "Asana"]),
  76. ]
  77. _initial_window: int | None = None
  78. def __enter__(self):
  79. self._initial_window = self.get_focused_window()
  80. return self
  81. def __exit__(self, exc_type, exc_value, traceback):
  82. if exc_type is not None:
  83. debug(f"Exited with {exc_type} {exc_value}")
  84. self.message(["rule", "--apply"])
  85. self.message(["window", "--focus", self._initial_window])
  86. if exc_type is None:
  87. debug(f"Executed successfully")
  88. def execute(self, *args, **kwargs):
  89. debug(f"Executing: ({args}), ({kwargs})")
  90. return check_output(*args, **kwargs)
  91. def message(self, args: list[str | int]) -> str:
  92. return self.execute(["yabai", "-m"] + [str(arg) for arg in args]).decode("utf8")
  93. def get_windows(self) -> set[Window]:
  94. return { Window(window) for window in loads(self.message(["query", "--windows"])) }
  95. def get_spaces(self) -> set[Space]:
  96. return {Space(space) for space in loads(self.message(["query", "--spaces"]))}
  97. def get_displays(self) -> set[Display]:
  98. return { Display(display) for display in loads(self.message(["query", "--displays"])) }
  99. def get_main_display(self, displays: set[Display] | None = None) -> Display:
  100. return sorted(list(displays if displays != None else self.get_displays()))[-1]
  101. def is_blank_space(self, space: Space) -> bool:
  102. return (
  103. space.label not in {s[0] for s in self.spaces}
  104. and not space.is_native_fullscreen
  105. )
  106. def manage_displays(self):
  107. displays = self.get_displays()
  108. main_display = self.get_main_display(displays)
  109. for display in displays:
  110. if display.index == main_display.index:
  111. self.message(["display", display.index, "--label", "Main"])
  112. else:
  113. self.message(
  114. ["display", display.index, "--label", f"Display {display.index}"]
  115. )
  116. def manage_spaces(self):
  117. initial_window = self.get_focused_window()
  118. # Start by making sure that the expected number of spaces are present
  119. spaces = self.get_spaces()
  120. occupied_spaces = {
  121. space
  122. for space in spaces
  123. if not self.is_blank_space(space) and not space.is_native_fullscreen
  124. }
  125. blank_spaces = {space for space in spaces if self.is_blank_space(space)}
  126. for _ in range(
  127. max(0, len(self.spaces) - (len(occupied_spaces) + len(blank_spaces)))
  128. ):
  129. self.message(["space", "--create"])
  130. # Use blank spaces to create occupied spaces as necessary
  131. spaces = self.get_spaces()
  132. blank_spaces = {space for space in spaces if self.is_blank_space(space)}
  133. created_space_labels: set[tuple[str, bool]] = set()
  134. for space_label, space_fullscreen, space_apps in self.spaces:
  135. if any(space.label == space_label for space in spaces):
  136. continue
  137. space = blank_spaces.pop()
  138. self.message(["space", space.index, "--label", space_label])
  139. created_space_labels.add((space_label, space_fullscreen))
  140. # Remove unnecessary spaces
  141. spaces = self.get_spaces()
  142. blank_spaces = [space for space in spaces if self.is_blank_space(space)]
  143. blank_spaces.sort(key=lambda s: s.index, reverse=True)
  144. # Make sure that the focused space isn't a blank one
  145. if any(space.has_focus for space in blank_spaces):
  146. self.message(["space", "--focus", self.spaces[0][0]])
  147. for space in blank_spaces:
  148. self.message(["space", "--destroy", space.index])
  149. # Configure the new spaces
  150. main_display = self.get_main_display()
  151. for label, fullscreen in created_space_labels:
  152. self.set_space_background(label)
  153. for label, fullscreen, apps in self.spaces:
  154. self.set_config(
  155. label,
  156. fullscreen=fullscreen,
  157. horizontal_padding=(main_display.frame["w"] - TARGET_DISPLAY_WIDTH) / 2,
  158. vertical_padding=(main_display.frame["h"] - TARGET_DISPLAY_HEIGHT) / 2,
  159. )
  160. if len(created_space_labels) > 0:
  161. self.message(["window", "--focus", initial_window])
  162. # Sort the remaining spaces
  163. for space_index, space_label in enumerate(self.spaces):
  164. try:
  165. self.message(["space", space_label[0], "--move", space_index + 1])
  166. except CalledProcessError:
  167. # Almost certainly thrown because space is already in place, so no problem
  168. pass
  169. # Return focus
  170. self.message(["window", "--focus", initial_window])
  171. info(f"Spaces configured: {sorted(self.get_spaces())}")
  172. def set_space_background(self, space: SpaceSel):
  173. try:
  174. self.message(["space", "--focus", space])
  175. except CalledProcessError:
  176. # Almost certainly thrown because space is already focused, so no problem
  177. pass
  178. self.execute(
  179. [
  180. "osascript",
  181. "-e",
  182. 'tell application "System Events" to tell every desktop to set picture to "/System/Library/Desktop Pictures/Solid Colors/Black.png"',
  183. ]
  184. )
  185. def set_config(
  186. self,
  187. space: SpaceSel,
  188. fullscreen: bool = False,
  189. gap: int = 10,
  190. vertical_padding: int = 0,
  191. horizontal_padding: int = 0,
  192. ):
  193. for config in [
  194. ["window_shadow", "float"],
  195. ["window_opacity", "on"],
  196. ["layout", "bsp"],
  197. ["top_padding", int(max(0 if fullscreen else gap, vertical_padding))],
  198. ["bottom_padding", int(max(0 if fullscreen else gap, vertical_padding))],
  199. ["left_padding", int(max(0 if fullscreen else gap, horizontal_padding))],
  200. ["right_padding", int(max(0 if fullscreen else gap, horizontal_padding))],
  201. ["window_gap", gap],
  202. ]:
  203. self.message(["config", "--space", space] + config)
  204. def set_rules_and_signals(self):
  205. # Reset rules and signals
  206. for domain in ["rule", "signal"]:
  207. for _ in range(len(loads(self.message([domain, "--list"])))):
  208. self.message([domain, "--remove", 0])
  209. # Load the system agent on dock restart
  210. self.message(
  211. [
  212. "signal",
  213. "--add",
  214. "label=SystemAgentReloadSignal",
  215. "event=dock_did_restart",
  216. "action=sudo yabai --load-sa",
  217. ]
  218. )
  219. # Reload spaces when displays are reset
  220. for reload_event in ["display_added", "display_removed"]:
  221. self.message(
  222. [
  223. "signal",
  224. "--add",
  225. f"label={reload_event}RestartSignal",
  226. f"event={reload_event}",
  227. f"action=/bin/zsh {XDG_CONFIG_HOME}/yabai/yabairc",
  228. ]
  229. )
  230. # Normal windows should be put on the desktop
  231. self.message(
  232. [
  233. "rule",
  234. "--add",
  235. "label=DefaultDesktopRule",
  236. "subrole=AXStandardWindow",
  237. "space=^Desktop",
  238. ]
  239. )
  240. # Rules for applications that get their own spaces
  241. for label, _, apps in self.spaces:
  242. for app in apps:
  243. if app == "*":
  244. continue
  245. self.message(
  246. [
  247. "rule",
  248. "--add",
  249. f"label={app}{label}Rule",
  250. f"app={app}",
  251. f"space=^{label}",
  252. ]
  253. )
  254. # Google Meet and Slack Huddles should be "sticky"
  255. for app, title in (("Google Meet", ".*"), ("Slack", "Huddle.*")):
  256. self.message(
  257. [
  258. "rule",
  259. "--add",
  260. f"label={app}VideoCallFloatingWindowRule",
  261. f"app={app}",
  262. f"title={title}",
  263. "sticky=on",
  264. "manage=on",
  265. "opacity=0.9",
  266. "grid=10:10:6:6:4:4",
  267. ]
  268. )
  269. # Compile SurfingKeys configuration when Firefox is launched
  270. self.message(
  271. [
  272. "signal",
  273. "--add",
  274. "event=application_launched",
  275. "app=Firefox",
  276. "label=FirefoxCompileExtensionsSignal",
  277. f"action=/bin/zsh {XDG_CONFIG_HOME}/surfingkeys/compile.sh",
  278. ]
  279. )
  280. # Check if dark mode settings have been updated when focusing terminal
  281. self.message(
  282. [
  283. "signal",
  284. "--add",
  285. "event=window_focused",
  286. "app=Alacritty",
  287. "label=AlacrittyCheckDarkMode",
  288. f"action=/bin/zsh {HOME}/.scripts/lightmode.zsh",
  289. ]
  290. )
  291. # When focusing an that may share a space with at least two others, rotate the
  292. # space's windows to ensure that the focused window is the largest.
  293. for label, _, apps in self.spaces:
  294. if len(apps) < 3:
  295. continue
  296. for app in apps:
  297. self.message(
  298. [
  299. "signal",
  300. "--add",
  301. "event=window_focused",
  302. f"app={app}",
  303. f"label={app}RotateToFocus",
  304. f"action=python3 {XDG_CONFIG_HOME}/yabai/yabai.py rotate",
  305. ]
  306. )
  307. def rotate(self) -> None:
  308. windows = self.get_windows()
  309. focus_window = next(w for w in windows if w.has_focus)
  310. same_space_windows = sorted((w for w in windows if w.space == focus_window.space), key = lambda w: w.size)
  311. if same_space_windows[-1].has_focus:
  312. return
  313. self.message(["window", focus_window.id, "--swap", same_space_windows[-1].id])
  314. return
  315. def get_focused_window(self) -> int:
  316. return [
  317. window
  318. for window in loads(self.message(["query", "--windows"]))
  319. if window["has-focus"]
  320. ].pop()["id"]
  321. if __name__ == "__main__":
  322. basicConfig(level=NOTSET)
  323. debug(f"Called with parameters {argv}")
  324. if argv[1] == "manage" or argv[1] == "initialize":
  325. with Yabai() as yabai:
  326. yabai.manage_displays()
  327. yabai.manage_spaces()
  328. if argv[1] == "initialize":
  329. yabai.set_rules_and_signals()
  330. if argv[1] == "rotate":
  331. Yabai().rotate()