yabai.py 16 KB

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