0
0

yabai.py 12 KB

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