yabai.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  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.display: int = data["display"]
  40. self.windows: list[int] = data["label"]
  41. self.is_visible: str = data["is-visible"]
  42. self.has_focus: bool = data["has-focus"]
  43. self.is_native_fullscreen: bool = data["is-native-fullscreen"]
  44. @override
  45. def __repr__(self) -> str:
  46. return (
  47. f"Space({self.label if self.label and len(self.label) > 0 else '<NoLabel>'}"
  48. f", {self.index}"
  49. f"{', Fullscreen' if self.is_native_fullscreen else ''}"
  50. f"{', Focused' if self.has_focus else ''})"
  51. )
  52. def __gt__(self, other: "Space"):
  53. return self.index.__gt__(other.index)
  54. class YabaiDisplay:
  55. def __init__(self, data: dict[str, Any]):
  56. self.id: int = data["id"]
  57. self.uuid: str = data["uuid"]
  58. self.index: int = data["index"]
  59. self.label: str = data["label"]
  60. self.has_focus: bool = data["has-focus"]
  61. self.spaces: list[str] = data["spaces"]
  62. self.frame: dict[str, int] = data["frame"]
  63. @override
  64. def __repr__(self) -> str:
  65. return (
  66. f"Display({self.label if self.label and len(self.label) > 0 else '<NoLabel>'}"
  67. f", {self.index}"
  68. f", Frame ({self.frame})"
  69. f"{', Focused' if self.has_focus else ''})"
  70. )
  71. def __gt__(self, other: "YabaiDisplay"):
  72. return self.frame["w"].__gt__(other.frame["w"])
  73. SpaceSel: TypeAlias = int | str
  74. class CLIWrapper:
  75. _base_args: list[str] = []
  76. def message(self, args: list[str | int]) -> str:
  77. return self.execute(self._base_args + [str(arg) for arg in args]).decode("utf8")
  78. def execute(self, *args: Any, **kwargs: Any) -> Any:
  79. debug(f"Executing: ({args}), ({kwargs})")
  80. return cast(str, check_output(*args, **kwargs))
  81. class Yabai(CLIWrapper):
  82. _base_args: list[str] = ["yabai", "-m"]
  83. spaces: list[tuple[str, bool, list[str], bool]] = [
  84. ("Desktop", False, ["*"], True),
  85. ("Finder", False, ["Finder"], False),
  86. ("Terminal", True, ["Alacritty"], False),
  87. ("Browser", True, ["Firefox"], False),
  88. ]
  89. _initial_window: Window | None = None
  90. _dual_display: None | Literal[True] | Literal[False] = None
  91. _exit_with_rule_apply: bool = False
  92. def __init__(self):
  93. self._dual_display = len(self.get_displays()) > 1
  94. if self._dual_display:
  95. self.spaces.append(
  96. (
  97. "Communication",
  98. False,
  99. ["Slack", "Signal", "Spotify"],
  100. True,
  101. )
  102. )
  103. for application in [
  104. "Google Meet",
  105. "Obsidian",
  106. "Asana",
  107. "Notion",
  108. "Netflix",
  109. "YouTube",
  110. ]:
  111. self.spaces.append(
  112. (
  113. application,
  114. True,
  115. [application],
  116. True,
  117. )
  118. )
  119. else:
  120. self.spaces.append(
  121. (
  122. "Communication",
  123. False,
  124. ["Slack", "Signal", "Spotify", "Notion"],
  125. False,
  126. )
  127. )
  128. self.spaces.append(("Notetaking", True, ["Obsidian", "Asana"], False))
  129. def __enter__(self):
  130. self._initial_window = self.get_focused_window()
  131. return self
  132. def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any):
  133. if exc_type is not None:
  134. debug(f"Exited with {exc_type} {exc_value}")
  135. if self._exit_with_rule_apply:
  136. self.message(["rule", "--apply"])
  137. if self._initial_window is not None:
  138. self.message(["window", "--focus", self._initial_window.id])
  139. if exc_type is None:
  140. debug(f"Executed successfully")
  141. def get_windows(self) -> set[Window]:
  142. return {
  143. Window(window) for window in loads(self.message(["query", "--windows"]))
  144. }
  145. def get_spaces(self) -> set[Space]:
  146. return {Space(space) for space in loads(self.message(["query", "--spaces"]))}
  147. def get_displays(self) -> set[YabaiDisplay]:
  148. return {
  149. YabaiDisplay(display)
  150. for display in loads(self.message(["query", "--displays"]))
  151. }
  152. def get_main_display(
  153. self, displays: set[YabaiDisplay] | None = None
  154. ) -> YabaiDisplay:
  155. return sorted(list(displays if displays != None else self.get_displays()))[-1]
  156. def is_blank_space(self, space: Space) -> bool:
  157. return (
  158. space.label not in {s[0] for s in self.spaces}
  159. and not space.is_native_fullscreen
  160. )
  161. def manage_displays(self):
  162. displays = self.get_displays()
  163. main_display = self.get_main_display(displays)
  164. for display in displays:
  165. if display.index == main_display.index:
  166. self.message(["display", display.index, "--label", "Main"])
  167. elif display.index == main_display.index + 1:
  168. self.message(["display", display.index, "--label", f"Secondary"])
  169. else:
  170. self.message(
  171. [
  172. "display",
  173. display.index,
  174. "--label",
  175. f"YabaiDisplay {display.index}",
  176. ]
  177. )
  178. def manage_spaces(self):
  179. initial_window = self.get_focused_window()
  180. # Start by making sure that the expected number of spaces are present
  181. spaces = self.get_spaces()
  182. occupied_spaces = {
  183. space
  184. for space in spaces
  185. if not self.is_blank_space(space) and not space.is_native_fullscreen
  186. }
  187. blank_spaces = {space for space in spaces if self.is_blank_space(space)}
  188. for _ in range(
  189. max(0, len(self.spaces) - (len(occupied_spaces) + len(blank_spaces)))
  190. ):
  191. self.message(["space", "--create"])
  192. # Use blank spaces to create occupied spaces as necessary
  193. spaces = self.get_spaces()
  194. blank_spaces = {space for space in spaces if self.is_blank_space(space)}
  195. created_space_labels: set[tuple[str, bool]] = set()
  196. for space_label, space_fullscreen, _, _ in self.spaces:
  197. if any(space.label == space_label for space in spaces):
  198. continue
  199. space = blank_spaces.pop()
  200. self.message(["space", space.index, "--label", space_label])
  201. created_space_labels.add((space_label, space_fullscreen))
  202. # Remove unnecessary spaces
  203. spaces = self.get_spaces()
  204. blank_spaces = [space for space in spaces if self.is_blank_space(space)]
  205. blank_spaces.sort(key=lambda s: s.index, reverse=True)
  206. # Make sure that the focused space isn't a blank one
  207. if any(space.has_focus for space in blank_spaces):
  208. self.message(["space", "--focus", self.spaces[0][0]])
  209. for space in blank_spaces:
  210. self.message(["space", "--destroy", space.index])
  211. # Configure the new spaces
  212. main_display = self.get_main_display()
  213. for label, fullscreen in created_space_labels:
  214. self.set_space_background(label)
  215. for label, fullscreen, _, _ in self.spaces:
  216. self.set_config(
  217. label,
  218. fullscreen=fullscreen,
  219. horizontal_padding=int(
  220. (main_display.frame["w"] - TARGET_DISPLAY_WIDTH) / 2
  221. ),
  222. vertical_padding=int(
  223. (main_display.frame["h"] - TARGET_DISPLAY_HEIGHT) / 2
  224. ),
  225. )
  226. if len(created_space_labels) > 0 and initial_window is not None:
  227. self.message(["window", "--focus", initial_window.id])
  228. # Return focus
  229. if initial_window is not None:
  230. self.message(["window", "--focus", initial_window.id])
  231. info(f"Spaces configured: {sorted(self.get_spaces())}")
  232. def set_space_background(self, space: SpaceSel):
  233. try:
  234. self.message(["space", "--focus", space])
  235. except CalledProcessError:
  236. # Almost certainly thrown because space is already focused, so no problem
  237. pass
  238. self.execute(
  239. [
  240. "osascript",
  241. "-e",
  242. 'tell application "System Events" to tell every desktop to set picture to "/System/Library/Desktop Pictures/Solid Colors/Black.png"',
  243. ]
  244. )
  245. def set_config(
  246. self,
  247. space: SpaceSel,
  248. fullscreen: bool = False,
  249. gap: int = 10,
  250. vertical_padding: int = 0,
  251. horizontal_padding: int = 0,
  252. ):
  253. for config in [
  254. ["window_shadow", "float"],
  255. ["window_opacity", "on"],
  256. ["layout", "bsp"],
  257. ["top_padding", int(max(0 if fullscreen else gap, vertical_padding))],
  258. ["bottom_padding", int(max(0 if fullscreen else gap, vertical_padding))],
  259. ["left_padding", int(max(0 if fullscreen else gap, horizontal_padding))],
  260. ["right_padding", int(max(0 if fullscreen else gap, horizontal_padding))],
  261. ["window_gap", gap],
  262. ]:
  263. self.message(["config", "--space", space] + config)
  264. def set_rules_and_signals(self):
  265. # Reset rules and signals
  266. for domain in ["rule", "signal"]:
  267. for _ in range(len(loads(self.message([domain, "--list"])))):
  268. self.message([domain, "--remove", 0])
  269. # Load the system agent on dock restart
  270. self.message(
  271. [
  272. "signal",
  273. "--add",
  274. "label=SystemAgentReloadSignal",
  275. "event=dock_did_restart",
  276. "action=sudo yabai --load-sa",
  277. ]
  278. )
  279. # Reload spaces when displays are reset
  280. for reload_event in ["display_added", "display_removed"]:
  281. self.message(
  282. [
  283. "signal",
  284. "--add",
  285. f"label={reload_event}RestartSignal",
  286. f"event={reload_event}",
  287. f"action=/bin/zsh {XDG_CONFIG_HOME}/yabai/yabairc",
  288. ]
  289. )
  290. # Normal windows should be put on the desktop
  291. self.message(
  292. [
  293. "rule",
  294. "--add",
  295. "label=DefaultDesktopRule",
  296. "subrole=AXStandardWindow",
  297. "space=^Desktop",
  298. ]
  299. )
  300. # Rules for applications that get their own spaces
  301. for label, _, apps, _ in self.spaces:
  302. for app in apps:
  303. if app == "*":
  304. continue
  305. self.message(
  306. [
  307. "rule",
  308. "--add",
  309. f"label={app}{label}Rule",
  310. f"app={app}",
  311. f"space=^{label}",
  312. ]
  313. )
  314. # Compile SurfingKeys configuration when Firefox is launched
  315. self.message(
  316. [
  317. "signal",
  318. "--add",
  319. "event=application_launched",
  320. "app=Firefox",
  321. "label=FirefoxCompileExtensionsSignal",
  322. f"action=/bin/zsh {XDG_CONFIG_HOME}/surfingkeys/compile.sh",
  323. ]
  324. )
  325. # Run Slack client script (populating cached values) when Slack is launched
  326. self.message(
  327. [
  328. "signal",
  329. "--add",
  330. "event=application_launched",
  331. "app=Slack",
  332. "label=SlackRunSlackClientScript",
  333. f"action=/usr/bin/env python3 {HOME}/.scripts/slack_client.py",
  334. ]
  335. )
  336. # "Attach" OBS to Google Meet
  337. self.message(
  338. [
  339. "signal",
  340. "--add",
  341. "event=application_launched",
  342. "app=Google Meet",
  343. "label=GoogleMeetOBSJoiner",
  344. f"action=/usr/bin/env python3 {HOME}/.scripts/obs_client.py Activate Default StartVirtualCam",
  345. ]
  346. )
  347. self.message(
  348. [
  349. "signal",
  350. "--add",
  351. "event=application_terminated",
  352. "app=Google Meet",
  353. "label=GoogleMeetOBSJoinerEnd",
  354. f"action=/usr/bin/env python3 {HOME}/.scripts/obs_client.py StopVirtualCam Activate Disabled",
  355. ]
  356. )
  357. # Check if dark mode settings have been updated when focusing terminal
  358. self.message(
  359. [
  360. "signal",
  361. "--add",
  362. "event=window_focused",
  363. "app=Alacritty",
  364. "label=AlacrittyCheckDarkMode",
  365. f"action=/bin/zsh {HOME}/.scripts/lightmode.zsh",
  366. ]
  367. )
  368. for trigger in [
  369. "display_added",
  370. "display_removed",
  371. "display_resized",
  372. "system_woke",
  373. ]:
  374. self.message(
  375. [
  376. "signal",
  377. "--add",
  378. f"event={trigger}",
  379. f"label=DisplayChange{trigger}",
  380. f"action=/usr/bin/env python3 {HOME}/.config/yabai.py manage",
  381. ]
  382. )
  383. # Rules that differ for one or multiple displays
  384. if self._dual_display:
  385. # space
  386. self.message(
  387. [
  388. "signal",
  389. "--add",
  390. "event=space_changed",
  391. "label=SpaceChanged",
  392. f"action=/usr/bin/env python3 {HOME}/.config/yabai.py move",
  393. ]
  394. )
  395. else:
  396. # Google Meet and Slack Huddles should be "sticky"
  397. for app, title in (("Google Meet", ".*"), ("Slack", "Huddle.*")):
  398. self.message(
  399. [
  400. "rule",
  401. "--add",
  402. f"label={app}VideoCallFloatingWindowRule",
  403. f"app={app}",
  404. f"title={title}",
  405. "sticky=on",
  406. "manage=on",
  407. "opacity=0.9",
  408. "grid=10:10:6:6:4:4",
  409. ]
  410. )
  411. # Tiny streaming player
  412. self.message(
  413. [
  414. "rule",
  415. "--add",
  416. f"label=NetflixFloatingWindowRule",
  417. f"app=Netflix",
  418. "sticky=on",
  419. "manage=on",
  420. ]
  421. )
  422. def move_spaces(self):
  423. initial_window = self.get_focused_window()
  424. final_window = self.get_focused_window()
  425. displays = self.get_displays()
  426. main_display = self.get_main_display(displays)
  427. secondary_display = next(
  428. (d for d in displays if not d.id == main_display.id), None
  429. )
  430. current_spaces = self.get_spaces()
  431. incorrect_main_spaces: set[Space] = set()
  432. incorrect_secondary_spaces: set[Space] = set()
  433. for space_label, _, _, is_secondary_space in self.spaces:
  434. mapped_space = next(
  435. (s for s in current_spaces if s.label == space_label), None
  436. )
  437. if mapped_space is None:
  438. continue
  439. if (
  440. mapped_space.display == main_display.index
  441. and is_secondary_space
  442. and secondary_display
  443. ):
  444. incorrect_secondary_spaces.add(mapped_space)
  445. if mapped_space.display != main_display.index and not is_secondary_space:
  446. incorrect_main_spaces.add(mapped_space)
  447. while len(incorrect_main_spaces) > 0 and len(incorrect_secondary_spaces) > 0:
  448. self.message(
  449. [
  450. "space",
  451. incorrect_main_spaces.pop().label,
  452. "--switch",
  453. incorrect_secondary_spaces.pop().label,
  454. ]
  455. )
  456. for spaces, secondary in (
  457. (incorrect_main_spaces, False),
  458. (incorrect_secondary_spaces, True),
  459. ):
  460. for space in spaces:
  461. self.message(
  462. [
  463. "space",
  464. space.label,
  465. "--display",
  466. secondary_display.label
  467. if secondary and secondary_display
  468. else main_display.label,
  469. ]
  470. )
  471. if (
  472. final_window is not None
  473. and initial_window is not None
  474. and final_window.id != initial_window.id
  475. ):
  476. self.message(["window", "--focus", initial_window.id])
  477. def get_focused_window(self) -> Window | None:
  478. windows = [
  479. window
  480. for window in [
  481. Window(window) for window in loads(self.message(["query", "--windows"]))
  482. ]
  483. if window.has_focus
  484. ]
  485. if len(windows) > 0:
  486. return windows.pop()
  487. return None
  488. def enable_exit_with_rule_apply(self):
  489. self._exit_with_rule_apply = True
  490. if __name__ == "__main__":
  491. basicConfig(level=NOTSET)
  492. debug(f"Called with parameters {argv}")
  493. with Yabai() as yabai:
  494. if argv[1] == "manage" or argv[1] == "initialize":
  495. yabai.enable_exit_with_rule_apply()
  496. yabai.manage_displays()
  497. yabai.manage_spaces()
  498. if argv[1] == "initialize":
  499. yabai.set_rules_and_signals()
  500. if argv[1] == "move" or argv[1] == "manage" or argv[1] == "initialize":
  501. yabai.move_spaces()