0
0

yabai.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  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 Any, 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[str, Any]):
  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[str, Any]):
  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[str, Any]):
  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[str, int] = 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: Any, exc_value: Any, traceback: Any):
  82. if exc_type is not None:
  83. debug(f"Exited with {exc_type} {exc_value}")
  84. self.message(["rule", "--apply"])
  85. if self._initial_window is not None:
  86. self.message(["window", "--focus", self._initial_window])
  87. if exc_type is None:
  88. debug(f"Executed successfully")
  89. def execute(self, *args: Any, **kwargs: Any) -> Any:
  90. debug(f"Executing: ({args}), ({kwargs})")
  91. return check_output(*args, **kwargs)
  92. def message(self, args: list[str | int]) -> str:
  93. return self.execute(["yabai", "-m"] + [str(arg) for arg in args]).decode("utf8")
  94. def get_windows(self) -> set[Window]:
  95. return {
  96. Window(window) for window in loads(self.message(["query", "--windows"]))
  97. }
  98. def get_spaces(self) -> set[Space]:
  99. return {Space(space) for space in loads(self.message(["query", "--spaces"]))}
  100. def get_displays(self) -> set[Display]:
  101. return {
  102. Display(display) for display in loads(self.message(["query", "--displays"]))
  103. }
  104. def get_main_display(self, displays: set[Display] | None = None) -> Display:
  105. return sorted(list(displays if displays != None else self.get_displays()))[-1]
  106. def is_blank_space(self, space: Space) -> bool:
  107. return (
  108. space.label not in {s[0] for s in self.spaces}
  109. and not space.is_native_fullscreen
  110. )
  111. def manage_displays(self):
  112. displays = self.get_displays()
  113. main_display = self.get_main_display(displays)
  114. for display in displays:
  115. if display.index == main_display.index:
  116. self.message(["display", display.index, "--label", "Main"])
  117. else:
  118. self.message(
  119. ["display", display.index, "--label", f"Display {display.index}"]
  120. )
  121. def manage_spaces(self):
  122. initial_window = self.get_focused_window()
  123. # Start by making sure that the expected number of spaces are present
  124. spaces = self.get_spaces()
  125. occupied_spaces = {
  126. space
  127. for space in spaces
  128. if not self.is_blank_space(space) and not space.is_native_fullscreen
  129. }
  130. blank_spaces = {space for space in spaces if self.is_blank_space(space)}
  131. for _ in range(
  132. max(0, len(self.spaces) - (len(occupied_spaces) + len(blank_spaces)))
  133. ):
  134. self.message(["space", "--create"])
  135. # Use blank spaces to create occupied spaces as necessary
  136. spaces = self.get_spaces()
  137. blank_spaces = {space for space in spaces if self.is_blank_space(space)}
  138. created_space_labels: set[tuple[str, bool]] = set()
  139. for space_label, space_fullscreen, _ in self.spaces:
  140. if any(space.label == space_label for space in spaces):
  141. continue
  142. space = blank_spaces.pop()
  143. self.message(["space", space.index, "--label", space_label])
  144. created_space_labels.add((space_label, space_fullscreen))
  145. # Remove unnecessary spaces
  146. spaces = self.get_spaces()
  147. blank_spaces = [space for space in spaces if self.is_blank_space(space)]
  148. blank_spaces.sort(key=lambda s: s.index, reverse=True)
  149. # Make sure that the focused space isn't a blank one
  150. if any(space.has_focus for space in blank_spaces):
  151. self.message(["space", "--focus", self.spaces[0][0]])
  152. for space in blank_spaces:
  153. self.message(["space", "--destroy", space.index])
  154. # Configure the new spaces
  155. main_display = self.get_main_display()
  156. for label, fullscreen in created_space_labels:
  157. self.set_space_background(label)
  158. for label, fullscreen, _ in self.spaces:
  159. self.set_config(
  160. label,
  161. fullscreen=fullscreen,
  162. horizontal_padding=int(
  163. (main_display.frame["w"] - TARGET_DISPLAY_WIDTH) / 2
  164. ),
  165. vertical_padding=int(
  166. (main_display.frame["h"] - TARGET_DISPLAY_HEIGHT) / 2
  167. ),
  168. )
  169. if len(created_space_labels) > 0:
  170. self.message(["window", "--focus", initial_window])
  171. # Sort the remaining spaces
  172. for space_index, space_label in enumerate(self.spaces):
  173. try:
  174. self.message(["space", space_label[0], "--move", space_index + 1])
  175. except CalledProcessError:
  176. # Almost certainly thrown because space is already in place, so no problem
  177. pass
  178. # Return focus
  179. self.message(["window", "--focus", initial_window])
  180. info(f"Spaces configured: {sorted(self.get_spaces())}")
  181. def set_space_background(self, space: SpaceSel):
  182. try:
  183. self.message(["space", "--focus", space])
  184. except CalledProcessError:
  185. # Almost certainly thrown because space is already focused, so no problem
  186. pass
  187. self.execute(
  188. [
  189. "osascript",
  190. "-e",
  191. 'tell application "System Events" to tell every desktop to set picture to "/System/Library/Desktop Pictures/Solid Colors/Black.png"',
  192. ]
  193. )
  194. def set_config(
  195. self,
  196. space: SpaceSel,
  197. fullscreen: bool = False,
  198. gap: int = 10,
  199. vertical_padding: int = 0,
  200. horizontal_padding: int = 0,
  201. ):
  202. for config in [
  203. ["window_shadow", "float"],
  204. ["window_opacity", "on"],
  205. ["layout", "bsp"],
  206. ["top_padding", int(max(0 if fullscreen else gap, vertical_padding))],
  207. ["bottom_padding", int(max(0 if fullscreen else gap, vertical_padding))],
  208. ["left_padding", int(max(0 if fullscreen else gap, horizontal_padding))],
  209. ["right_padding", int(max(0 if fullscreen else gap, horizontal_padding))],
  210. ["window_gap", gap],
  211. ]:
  212. self.message(["config", "--space", space] + config)
  213. def set_rules_and_signals(self):
  214. # Reset rules and signals
  215. for domain in ["rule", "signal"]:
  216. for _ in range(len(loads(self.message([domain, "--list"])))):
  217. self.message([domain, "--remove", 0])
  218. # Load the system agent on dock restart
  219. self.message(
  220. [
  221. "signal",
  222. "--add",
  223. "label=SystemAgentReloadSignal",
  224. "event=dock_did_restart",
  225. "action=sudo yabai --load-sa",
  226. ]
  227. )
  228. # Reload spaces when displays are reset
  229. for reload_event in ["display_added", "display_removed"]:
  230. self.message(
  231. [
  232. "signal",
  233. "--add",
  234. f"label={reload_event}RestartSignal",
  235. f"event={reload_event}",
  236. f"action=/bin/zsh {XDG_CONFIG_HOME}/yabai/yabairc",
  237. ]
  238. )
  239. # Normal windows should be put on the desktop
  240. self.message(
  241. [
  242. "rule",
  243. "--add",
  244. "label=DefaultDesktopRule",
  245. "subrole=AXStandardWindow",
  246. "space=^Desktop",
  247. ]
  248. )
  249. # Rules for applications that get their own spaces
  250. for label, _, apps in self.spaces:
  251. for app in apps:
  252. if app == "*":
  253. continue
  254. self.message(
  255. [
  256. "rule",
  257. "--add",
  258. f"label={app}{label}Rule",
  259. f"app={app}",
  260. f"space=^{label}",
  261. ]
  262. )
  263. # Google Meet and Slack Huddles should be "sticky"
  264. for app, title in (("Google Meet", ".*"), ("Slack", "Huddle.*")):
  265. self.message(
  266. [
  267. "rule",
  268. "--add",
  269. f"label={app}VideoCallFloatingWindowRule",
  270. f"app={app}",
  271. f"title={title}",
  272. "sticky=on",
  273. "manage=on",
  274. "opacity=0.9",
  275. "grid=10:10:6:6:4:4",
  276. ]
  277. )
  278. # Compile SurfingKeys configuration when Firefox is launched
  279. self.message(
  280. [
  281. "signal",
  282. "--add",
  283. "event=application_launched",
  284. "app=Firefox",
  285. "label=FirefoxCompileExtensionsSignal",
  286. f"action=/bin/zsh {XDG_CONFIG_HOME}/surfingkeys/compile.sh",
  287. ]
  288. )
  289. # Check if dark mode settings have been updated when focusing terminal
  290. self.message(
  291. [
  292. "signal",
  293. "--add",
  294. "event=window_focused",
  295. "app=Alacritty",
  296. "label=AlacrittyCheckDarkMode",
  297. f"action=/bin/zsh {HOME}/.scripts/lightmode.zsh",
  298. ]
  299. )
  300. def get_focused_window(self) -> int:
  301. return [
  302. window
  303. for window in loads(self.message(["query", "--windows"]))
  304. if window["has-focus"]
  305. ].pop()["id"]
  306. if __name__ == "__main__":
  307. basicConfig(level=NOTSET)
  308. debug(f"Called with parameters {argv}")
  309. with Yabai() as yabai:
  310. if argv[1] == "manage" or argv[1] == "initialize":
  311. yabai.manage_displays()
  312. yabai.manage_spaces()
  313. if argv[1] == "initialize":
  314. yabai.set_rules_and_signals()