yabai.py 22 KB

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