Skip to content

Commit

Permalink
initial support for mac
Browse files Browse the repository at this point in the history
  • Loading branch information
gu1p committed Sep 26, 2024
1 parent 88174b3 commit 2f2b83a
Show file tree
Hide file tree
Showing 4 changed files with 209 additions and 8 deletions.
9 changes: 6 additions & 3 deletions janela/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import platform
import shutil

from ._impl.wmctrl_xdotool_xlib import WindowManagerImpl
from .interfaces.interface import WindowManager as WM, Monitor, Window


__all__ = ["WindowManager", "Monitor", "Window", "playground"]


Expand All @@ -13,6 +11,8 @@ def WindowManager() -> WM:
raise NotImplementedError("Windows is not supported yet")

if platform.system().lower() == "linux":
from ._impl.wmctrl_xdotool_xlib import WindowManagerImpl

xdotool_path = shutil.which("xdotool")
wmctrl_path = shutil.which("wmctrl")

Expand All @@ -24,6 +24,9 @@ def WindowManager() -> WM:
return WindowManagerImpl(xdotool_path, wmctrl_path)

if platform.system().lower() == "darwin":
raise NotImplementedError("Mac is not supported yet")
from ._impl.mac import MacOSWindowManager

return MacOSWindowManager()


raise NotImplementedError("Unsupported platform")
195 changes: 195 additions & 0 deletions janela/_impl/mac.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import subprocess
from typing import List, Optional

from AppKit import NSScreen, NSWorkspace
from Quartz import (
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID,
CGWindowListCopyWindowInfo,
kCGWindowListOptionAll,
)

from janela.interfaces import WindowManager
from janela.interfaces.models import Monitor, Window


class MacOSWindowManager(WindowManager):
def get_monitors(self) -> List[Monitor]:
monitors = []
for screen in NSScreen.screens():
frame = screen.frame()
monitor = Monitor(
wm=self,
id=screen.deviceDescription()["NSScreenNumber"],
name=screen.localizedName(),
width=int(frame.size.width),
height=int(frame.size.height),
x=int(frame.origin.x),
y=int(frame.origin.y),
)
monitors.append(monitor)
return monitors

def get_active_window_id(self) -> str:
window_info = NSWorkspace.sharedWorkspace().frontmostApplication()
pid = window_info.processIdentifier()
window_list = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID)
for window in window_list:
if window["kCGWindowOwnerPID"] == pid:
return str(window["kCGWindowNumber"])
return ""

def list_windows(self) -> List[Window]:
windows = []
window_list = CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID)
for win in window_list:
owner_name = win.get("kCGWindowOwnerName", "")
window_name = win.get("kCGWindowName", "")
bounds = win.get("kCGWindowBounds", {})
window = Window(
wm=self,
id=str(win["kCGWindowNumber"]),
name=window_name or owner_name,
x=int(bounds.get("X", 0)),
y=int(bounds.get("Y", 0)),
width=int(bounds.get("Width", 0)),
height=int(bounds.get("Height", 0)),
)
windows.append(window)
return windows

def get_monitor_for_window(self, window: Window) -> Optional[Monitor]:
monitors = self.get_monitors()
for monitor in monitors:
if (
monitor.x <= window.x < monitor.x + monitor.width
and monitor.y <= window.y < monitor.y + monitor.height
):
return monitor
return None

def move_window_to_position(self, window: Window, x: int, y: int):
script = f'''
tell application "System Events"
set frontmost of the first process whose unix id is {window.pid} to true
tell application process "{window.name}"
set position of windows to {{{x}, {y}}}
end tell
end tell
'''
subprocess.call(["osascript", "-e", script])

def resize_window(self, window: Window, width: int, height: int):
script = f'''
tell application "System Events"
set frontmost of the first process whose unix id is {window.pid} to true
tell application process "{window.name}"
set size of windows to {{{width}, {height}}}
end tell
end tell
'''
subprocess.call(["osascript", "-e", script])

def minimize_window(self, window: Window):
script = f'''
tell application "System Events"
set miniaturized of windows of (first process whose unix id is {window.pid}) to true
end tell
'''
subprocess.call(["osascript", "-e", script])

def maximize_window(self, window: Window):
# macOS doesn't have a direct maximize command; we can simulate it by resizing
monitor = self.get_monitor_for_window(window)
if monitor:
self.move_window_to_position(window, monitor.x, monitor.y)
self.resize_window(window, monitor.width, monitor.height)

def move_to_monitor(self, window: Window, monitor: Monitor):
self.move_window_to_position(window, monitor.x, monitor.y)

def verify_window_move(
self, window: Window, target_monitor: Monitor, expected_x: int, expected_y: int
) -> bool:
# Refresh window information
updated_window = self.get_window_by_id(window.id)
if updated_window:
return updated_window.x == expected_x and updated_window.y == expected_y
return False

def get_window_by_id(self, window_id: str) -> Optional[Window]:
window_list = CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID)
for win in window_list:
if str(win["kCGWindowNumber"]) == window_id:
owner_name = win.get("kCGWindowOwnerName", "")
window_name = win.get("kCGWindowName", "")
bounds = win.get("kCGWindowBounds", {})
window = Window(
id=str(win["kCGWindowNumber"]),
name=window_name or owner_name,
x=int(bounds.get("X", 0)),
y=int(bounds.get("Y", 0)),
width=int(bounds.get("Width", 0)),
height=int(bounds.get("Height", 0)),
wm=self,
)
return window
return None

def focus_window(self, window: Window):
script = f'''
tell application "System Events"
set frontmost of the first process whose unix id is {window.pid} to true
end tell
'''
subprocess.call(["osascript", "-e", script])

def close_window(self, window: Window):
script = f'''
tell application "System Events"
tell (first process whose unix id is {window.pid})
tell window 1 to close
end tell
end tell
'''
subprocess.call(["osascript", "-e", script])

def list_monitors(self) -> List[Monitor]:
return sorted(self.get_monitors(), key=lambda m: m.name)

def get_active_window(self) -> Optional[Window]:
active_window_id = self.get_active_window_id()
return self.get_window_by_id(active_window_id)

def verify_window_positions(self) -> bool:
# Implement a method to verify all window positions if necessary
return True

def get_window_by_name(self, name: str) -> Optional[Window]:
window_list = self.list_windows()
for window in window_list:
if window.name == name:
return window
return None

def get_monitor_by_id(self, monitor_id: int) -> Optional[Monitor]:
monitors = self.get_monitors()
for monitor in monitors:
if monitor.id == monitor_id:
return monitor
return None

def is_window_maximized(self, window: Window) -> bool:
monitor = self.get_monitor_for_window(window)
if monitor:
return (
window.x == monitor.x
and window.y == monitor.y
and window.width == monitor.width
and window.height == monitor.height
)
return False

def unmaximize_window(self, window: Window) -> None:
# macOS doesn't have a direct unmaximize; you may need to restore to a default size
self.resize_window(window, 800, 600) # Example default size
4 changes: 1 addition & 3 deletions janela/_impl/wmctrl_xdotool_xlib.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from typing import List, Optional
import platform

if platform.platform().lower().startswith("linux"):
from Xlib import display, X
from Xlib import display, X

from janela.interfaces.interface import WindowManager
from janela.interfaces.models import Monitor, Window
Expand Down
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
[tool.poetry]
name = "janela"
version = "0.0.4"
version = "0.0.5"
description = "A simple tool to automate window management"
authors = ["Gustavo <[email protected]>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.8"
python-xlib = "^0.33"

# Linux-specific dependencies
python-xlib = { version = "^0.33", markers = "sys_platform == 'linux'" }

# macOS-specific dependencies
pyobjc = { version = "^10.0", markers = "sys_platform == 'darwin'" }
pyobjc-framework-Quartz = { version = "^10.0", markers = "sys_platform == 'darwin'" }

[tool.poetry.group.dev.dependencies]
black = "^24.8.0"
Expand Down

0 comments on commit 2f2b83a

Please sign in to comment.