0
0

yabai.py 16 KB

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