diff --git a/CHANGES.txt b/CHANGES.txt index d968730..4d2b9b6 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,6 @@ -0.0.12, 2023/08/24 -- ALL: findMonitor() returns a list of Monitor instances - MACOS: Added contrast(), setContrast(), isOn() and isAttached(), improved setMode() +0.1, 2023/08/25 -- ALL: Reorganized to avoid IDEs showing external and / or private elements + findMonitor() returns a list of Monitor instances + MACOS: Added contrast(), setContrast(), isOn() and isAttached(), improved setMode() 0.0.11, 2023/08/23 -- MACOS: Added display_manager_lib (thanks to University of Utah - Marriott Library - Apple Infrastructure) WIN32: Fixed setScale() 0.0.10, 2023/08/21 -- ALL: Fixed watchdog thread diff --git a/README.md b/README.md index 34e308c..03ca64a 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ Cross-Platform module which provides a set of features to get info on and control monitors. -#### My most sincere thanks and appreciation to the University of Utah Student Computing Labs for their awesome work on the display_manager_lib module, for sharing it so generously, and most especially for allowing to be integrated into PyMonCtl - External tools/extensions/APIs used: - Linux: - Xlib's randr extension @@ -16,6 +14,9 @@ External tools/extensions/APIs used: - macOS: - pmset command-line tool + +My most sincere thanks and appreciation to the University of Utah Student Computing Labs for their awesome work on the display_manager_lib module, for sharing it so generously, and most especially for allowing to be integrated into PyMonCtl + ## General Functions Functions to get monitor instances, get info and manage monitors plugged to the system. diff --git a/dist/PyMonCtl-0.0.12-py3-none-any.whl b/dist/PyMonCtl-0.1-py3-none-any.whl similarity index 51% rename from dist/PyMonCtl-0.0.12-py3-none-any.whl rename to dist/PyMonCtl-0.1-py3-none-any.whl index 8a55f5f..016c6a7 100644 Binary files a/dist/PyMonCtl-0.0.12-py3-none-any.whl and b/dist/PyMonCtl-0.1-py3-none-any.whl differ diff --git a/setup.py b/setup.py index e24c247..e491198 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ license='BSD 3', packages=find_packages(where='src'), package_dir={'': 'src'}, - package_data={"pymonctl": ["py.typed"]}, + package_data={"pymonctl": ["py.typed"], "ewmhlib": ["py.typed"]}, test_suite='tests', install_requires=[ "pywin32>=302; sys_platform == 'win32'", diff --git a/src/ewmhlib/Props.py b/src/ewmhlib/Props.py index 3019b59..1efca2c 100644 --- a/src/ewmhlib/Props.py +++ b/src/ewmhlib/Props.py @@ -135,5 +135,3 @@ class StackMode(IntEnum): class HintAction(IntEnum): KEEP = -1 REMOVE = -2 - - diff --git a/src/ewmhlib/Structs.py b/src/ewmhlib/Structs.py index b61a4f8..357e769 100644 --- a/src/ewmhlib/Structs.py +++ b/src/ewmhlib/Structs.py @@ -9,6 +9,14 @@ class ScreensInfo(TypedDict): + """ + Container class to handle ScreensInfo struct: + + - screen_number (str): int (sequential) + - is_default (bool): ''True'' if the screen is the default screen + - screen (Xlib.Struct): screen Struct (see Xlib documentation) + - root (Xlib.xobject.drawable.Window): root X-Window object belonging to screen + """ screen_number: str is_default: bool screen: Struct @@ -16,6 +24,13 @@ class ScreensInfo(TypedDict): class DisplaysInfo(TypedDict): + """ + Container class to handle DisplaysInfo struct: + + - name: Display name (as per Xlib.display.Display(name)) + - is_default: ''True'' if the display is the default display + - screens: list of ScreensInfo structs belonging to display + """ name: str is_default: bool screens: List[ScreensInfo] @@ -25,7 +40,22 @@ class DisplaysInfo(TypedDict): Perhaps unnecesary since structs below are defined in Xlib.xobject.icccm.*, though in a more complex way. """ class WmHints(TypedDict): - # {'flags': 103, 'input': 1, 'initial_state': 1, 'icon_pixmap': , 'icon_window': , 'icon_x': 0, 'icon_y': 0, 'icon_mask': , 'window_group': } + """ + Container class to handle WmHints struct: + + Example: + { + 'flags': 103, + 'input': 1, + 'initial_state': 1, + 'icon_pixmap': , + 'icon_window': , + 'icon_x': 0, + 'icon_y': 0, + 'icon_mask': , + 'window_group': + } + """ flags: int input_mode: int initial_state: int @@ -38,12 +68,31 @@ class WmHints(TypedDict): class Aspect(TypedDict): + """Container class to handle Aspect struct (num, denum)""" num: int denum: int class WmNormalHints(TypedDict): - # {'flags': 848, 'min_width': 387, 'min_height': 145, 'max_width': 0, 'max_height': 0, 'width_inc': 9, 'height_inc': 18, 'min_aspect': ({'num': 0, 'denum': 0}), 'max_aspect': ({'num': 0, 'denum': 0}), 'base_width': 66, 'base_height': 101, 'win_gravity': 1} + """ + Container class to handle WmNormalHints + + Example: + { + 'flags': 848, + 'min_width': 387, + 'min_height': 145, + 'max_width': 0, + 'max_height': 0, + 'width_inc': 9, + 'height_inc': 18, + 'min_aspect': ({'num': 0, 'denum': 0}), + 'max_aspect': ({'num': 0, 'denum': 0}), + 'base_width': 66, + 'base_height': 101, + 'win_gravity': 1 + } + """ flags: int min_width: int min_height: int diff --git a/src/ewmhlib/__init__.py b/src/ewmhlib/__init__.py index ec88e64..bf2ab5f 100644 --- a/src/ewmhlib/__init__.py +++ b/src/ewmhlib/__init__.py @@ -1,14 +1,5 @@ -from __future__ import annotations - -import sys -assert sys.platform == "linux" - -from ._ewmhlib import (displaysCount, getDisplaysNames, getDisplaysInfo, getDisplayFromRoot, getDisplayFromWindow, - getProperty, getPropertyValue, changeProperty, sendMessage, _xlibGetAllWindows, - defaultDisplay, defaultScreen, defaultRoot, RootWindow, defaultRootWindow, EwmhWindow - ) -import ewmhlib.Props as Props -import ewmhlib.Structs as Structs +#!/usr/bin/python +# -*- coding: utf-8 -*- __all__ = [ "version", "displaysCount", "getDisplaysNames", "getDisplaysInfo", "getDisplayFromRoot", "getDisplayFromWindow", @@ -17,10 +8,16 @@ "Props", "Structs" ] - __version__ = "0.0.1" def version(numberOnly: bool = True): """Returns the current version of ewmhlib module, in the form ''x.x.xx'' as string""" return ("" if numberOnly else "EWMHlib-")+__version__ + + +from ._main import (displaysCount, getDisplaysNames, getDisplaysInfo, getDisplayFromRoot, getDisplayFromWindow, + getProperty, getPropertyValue, changeProperty, sendMessage, + defaultDisplay, defaultScreen, defaultRoot, RootWindow, defaultRootWindow, EwmhWindow, + Props, Structs + ) diff --git a/src/ewmhlib/_main.py b/src/ewmhlib/_main.py new file mode 100644 index 0000000..0533c10 --- /dev/null +++ b/src/ewmhlib/_main.py @@ -0,0 +1,9 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +from ._ewmhlib import (displaysCount, getDisplaysNames, getDisplaysInfo, getDisplayFromRoot, getDisplayFromWindow, + getProperty, getPropertyValue, changeProperty, sendMessage, + defaultDisplay, defaultScreen, defaultRoot, RootWindow, defaultRootWindow, EwmhWindow + ) +import ewmhlib.Props as Props +import ewmhlib.Structs as Structs diff --git a/src/pymonctl/__init__.py b/src/pymonctl/__init__.py index 46c2861..82ca6b9 100644 --- a/src/pymonctl/__init__.py +++ b/src/pymonctl/__init__.py @@ -1,25 +1,16 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -from __future__ import annotations - -import sys -import threading -from abc import abstractmethod, ABC -from collections.abc import Callable -from typing import List, Optional, Union, Tuple - -from pymonctl.structs import DisplayMode, ScreenValue, Size, Point, Box, Rect, Position, Orientation __all__ = [ - "getAllMonitors", "getPrimary", "findMonitor", "findMonitorInfo", "arrangeMonitors", + "getAllMonitors", "getAllMonitorsDict", "getMonitorsCount", "getPrimary", "findMonitor", "findMonitorInfo", + "arrangeMonitors", "getMousePos", "version", "Monitor", "enableUpdateInfo", "disableUpdateInfo", "isUpdateInfoEnabled", "isWatchdogEnabled", "updateWatchdogInterval", "plugListenerRegister", "plugListenerUnregister", "isPlugListenerRegistered", "changeListenerRegister", "changeListenerUnregister", "isChangeListenerRegistered", - "DisplayMode", "ScreenValue", "Size", "Point", "Box", "Rect", "Position", "Orientation", - "getMousePos", "version", "Monitor" + "DisplayMode", "ScreenValue", "Size", "Point", "Box", "Rect", "Position", "Orientation" ] -__version__ = "0.0.12" +__version__ = "0.1" def version(numberOnly: bool = True) -> str: @@ -27,844 +18,10 @@ def version(numberOnly: bool = True) -> str: return ("" if numberOnly else "PyMonCtl-")+__version__ -def _pointInBox(x: int, y: int, left: int, top: int, width: int, height: int) -> bool: - """Returns ``True`` if the ``(x, y)`` point is within the box described - by ``(left, top, width, height)``.""" - return left <= x <= left + width and top <= y <= top + height - - -def getAllMonitors() -> list[Monitor]: - """ - Get the list with all Monitor instances from plugged monitors. - - In case you plan to use this function in a scenario in which it could be invoked quickly and repeatedly, - it's highly recommended to enable update watchdog (see enableUpdate() function). - - :return: list of Monitor instances - """ - global _updateScreens - if _updateScreens is None: - return _getAllMonitors() - else: - return _updateScreens.getMonitors() - - -def getAllMonitorsDict() -> dict[str, ScreenValue]: - """ - Get all monitors info plugged to the system, as a dict. - - In case you plan to use this function in a scenario in which it could be invoked quickly and repeatedly, - it's highly recommended to enable update watchdog (see enableUpdate() function). - - :return: Monitors info as python dictionary - - Output Format: - Key: - Display name (in macOS it is necessary to add handle to avoid duplicates) - - Values: - "system_name": - display name as returned by the system (in macOS, the name can be duplicated!) - "handle": - display handle according to each platform/OS - "is_primary": - ''True'' if monitor is primary (shows clock and notification area, sign in, lock, CTRL+ALT+DELETE screens...) - "position": - Point(x, y) struct containing the display position ((0, 0) for the primary screen) - "size": - Size(width, height) struct containing the display size, in pixels - "workarea": - Rect(left, top, right, bottom) struct with the screen workarea, in pixels - "scale": - Scale ratio, as a tuple of (x, y) scale percentage - "dpi": - Dots per inch, as a tuple of (x, y) dpi values - "orientation": - Display orientation: 0 - Landscape / 1 - Portrait / 2 - Landscape (reversed) / 3 - Portrait (reversed) - "frequency": - Refresh rate of the display, in Hz - "colordepth": - Bits per pixel referred to the display color depth - """ - global _updateScreens - if _updateScreens is None: - return _getAllMonitorsDict() - else: - return _updateScreens.getScreens() - - -def getMonitorsCount() -> int: - """ - Get the number of monitors currently connected to the system. - - :return: number of monitors as integer - """ - return _getMonitorsCount() - - -def getPrimary() -> Monitor: - """ - Get primary monitor instance. This is equivalent to invoking ''Monitor()'', with empty input params. - - :return: Monitor instance or None - """ - return _getPrimary() - - -def findMonitor(x: int, y: int) -> Optional[List[Monitor]]: - """ - Get monitor instance in which given coordinates (x, y) are found. - - :return: Monitor instance or None - """ - return _findMonitor(x, y) - - -def findMonitorInfo(x: int, y: int) -> dict[str, ScreenValue]: - """ - Get monitor info in which given coordinates (x, y) are found. - - :return: monitor info (see getAllMonitorsDict() doc) as dictionary, or empty - """ - info: dict[str, ScreenValue] = {} - monitors = getAllMonitorsDict() - for monitor in monitors.keys(): - pos = monitors[monitor]["position"] - size = monitors[monitor]["size"] - if _pointInBox(x, y, pos.x, pos.y, size.width, size.height): - info[monitor] = monitors[monitor] - break - return info - - -def arrangeMonitors(arrangement: dict[str, dict[str, Union[str, int, Position, Point, Size]]]): - """ - Arrange all monitors in a given shape. - - For that, you must pass a dict with the following structure: - "Monitor name": - monitor name as keys() returned by getAllMonitorsDict() (don't use "system_name" value for this) - "relativePos": - position of this monitor in relation to the monitor provided in ''relativeTo'' - "relativeTo": - monitor name to which ''relativePos'' is referred to (or None if PRIMARY) - - - You MUST pass the position of ALL monitors, and SET ONE of them as PRIMARY. - - HIGHLY RECOMMENDED: When building complex arrangements, start by the primary monitor and then build the rest - taking previous ones as references. - - EXAMPLE for a 3-Monitors setup in which second is at the left and third is on top of primary monitor: - - { - "Display_1": {"relativePos": Position.PRIMARY, "relativeTo": None}, - - "Display_2": {"relativePos": Position.LEFT_TOP, "relativeTo": "Display_1"}, - - "Display_3": {"relativePos": Position.ABOVE_LEFT, "relativeTo": "Display_1"} - } - - :param arrangement: arrangement structure as dict - """ - _arrangeMonitors(arrangement) - - -def getMousePos() -> Point: - """ - Get the current (x, y) coordinates of the mouse pointer on screen, in pixels - - :return: Point struct - """ - return _getMousePos() - - -class BaseMonitor(ABC): - - @property - @abstractmethod - def size(self) -> Optional[Size]: - """ - Get the dimensions of the monitor as a size struct (width, height) - - This property can not be set independently. To do so, choose an allowed mode (from monitor.allModes) - and set the monitor mode property (monitor.mode = selectedMode) - - :return: Size - """ - raise NotImplementedError - - @property - @abstractmethod - def workarea(self) -> Optional[Rect]: - """ - Get dimensions of the "usable by applications" area (screen size minus docks, taskbars and so on), as - a rect struct (x, y, right, bottom) - - This property can not be set. - - :return: Rect - """ - raise NotImplementedError - - @property - @abstractmethod - def position(self) -> Optional[Point]: - """ - Get monitor position coordinates as a point struct (x, y) - - This property can not be set. Use setPosition() method instead. - - :return: Point - """ - raise NotImplementedError - - @abstractmethod - def setPosition(self, relativePos: Union[int, Position], relativeTo: Optional[str]): - """ - Change relative position of the current the monitor in relation to another existing monitor (e.g. primary monitor). - - In general, it is HIGHLY recommendable to use arrangeMonitors() method instead of setPosition(), and most - especially in complex arrangements or setups with more than 2 monitors. - - Important issues: - - - On Windows, primary monitor is mandatory, and it is always placed in (0, 0) coordinates. Besides, the monitors can not overlap. In case the monitor you want to reposition is the primary or the unique one, it will have no effect. To do so, you must switch the primary monitor first, then reposition it. - - - On Linux, primary monitor can be anywhere, monitors can overlap and even there can be no primary monitor - - - On macOS, tests in multi-monitor setups are still required to confirm these behaviors and produce a final version - - :param relativePos: position in relation to another existing monitor (e.g. primary) as per Positions.* - :param relativeTo: monitor in relation to which this monitor must be placed - """ - raise NotImplementedError - - @property - @abstractmethod - def box(self) -> Optional[Box]: - """ - Get monitor dimensions as a box struct (x, y, width, height) - - This property can not be set. - - :return: Box - """ - raise NotImplementedError - - @property - @abstractmethod - def rect(self) -> Optional[Rect]: - """ - Get monitor dimensions as a rect struct (x, y, right, bottom) - - This property can not be set. - - :return: Rect - """ - raise NotImplementedError - - @property - @abstractmethod - def scale(self) -> Optional[Tuple[float, float]]: - """ - Get scale for the monitor - - Note not all scales will be allowed for all monitors and/or modes - """ - raise NotImplementedError - - @abstractmethod - def setScale(self, scale: Tuple[float, float]): - """ - Change scale for the monitor - - Note not all scales will be allowed for all monitors and/or modes - - :param scale: target percentage as float value - """ - raise NotImplementedError - - @property - @abstractmethod - def dpi(self) -> Optional[Tuple[float, float]]: - """ - Get the dpi (dots/pixels per inch) value for the monitor - - This property can not be set - """ - raise NotImplementedError - - @property - @abstractmethod - def orientation(self) -> Optional[Union[int, Orientation]]: - """ - Get current orientation for the monitor identified by name (or primary if empty) - - The available orientations are: - 0 - 0 degrees (normal) - 1 - 90 degrees (right) - 2 - 180 degrees (inverted) - 3 - 270 degrees (left) - """ - raise NotImplementedError - - @abstractmethod - def setOrientation(self, orientation: Optional[Union[int, Orientation]]): - """ - Change orientation for the monitor identified by name (or primary if empty) - - The available orientations are: - 0 - 0 degrees (normal) - 1 - 90 degrees (right) - 2 - 180 degrees (inverted) - 3 - 270 degrees (left) - - :param orientation: orientation as per Orientations.* - """ - raise NotImplementedError - - @property - @abstractmethod - def frequency(self) -> Optional[float]: - """ - Get current refresh rate of monitor. - - This property can not be set independently. To do so, choose an allowed mode (from monitor.allModes) - and set the monitor mode property (monitor.mode = selectedMode) - """ - raise NotImplementedError - - @property - @abstractmethod - def colordepth(self) -> Optional[int]: - """ - Get the colordepth (bits per pixel to describe color) value for the monitor - - This property can not be set - """ - raise NotImplementedError - - @property - @abstractmethod - def brightness(self) -> Optional[int]: - """ - Get the brightness of monitor. The return value is normalized to 0-100 (as a percentage) - - :return: brightness as float - """ - raise NotImplementedError - - @abstractmethod - def setBrightness(self, brightness: Optional[int]): - """ - Change the brightness of monitor. The input parameter must be defined as a percentage (0-100) - """ - raise NotImplementedError - - @property - @abstractmethod - def contrast(self) -> Optional[int]: - """ - Get the contrast of monitor. The return value is normalized to 0-100 (as a percentage) - - WARNING: In Linux and macOS contrast is calculated from Gamma RGB values. - - :return: contrast as float - """ - raise NotImplementedError - - @abstractmethod - def setContrast(self, contrast: Optional[int]): - """ - Change the contrast of monitor. The input parameter must be defined as a percentage (0-100) - - WARNING: In Linux and macOS the change will apply to Gamma homogeneously for all color components (R, G, B). - - Example for Linux: A value of 50.0 (50%), will result in a Gamma of ''0.5:0.5:0.5'' - """ - raise NotImplementedError - - @property - @abstractmethod - def mode(self) -> Optional[DisplayMode]: - """ - Get the current monitor mode (width, height, refresh-rate) for the monitor - - :return: current mode as DisplayMode struct - """ - raise NotImplementedError - - @abstractmethod - def setMode(self, mode: Optional[DisplayMode]): - """ - Change current monitor mode (resolution and/or refresh-rate) for the monitor - - The mode must be one of the allowed modes by the monitor (see allModes property). - - :param mode: target mode as DisplayMode (width, height and frequency) - """ - raise NotImplementedError - - @property - @abstractmethod - def defaultMode(self) -> Optional[DisplayMode]: - """ - Get the preferred mode for the monitor - - :return: DisplayMode struct (width, height, frequency) - """ - raise NotImplementedError - - @abstractmethod - def setDefaultMode(self): - """ - Change current mode to default / preferred mode - """ - raise NotImplementedError - - @property - @abstractmethod - def allModes(self) -> list[DisplayMode]: - """ - Get all allowed modes for the monitor - - :return: list of DisplayMode (width, height, frequency) - """ - raise NotImplementedError - - @property - def isPrimary(self) -> bool: - """ - Check if given monitor is primary. - - :return: ''True'' if given monitor is primary, ''False'' otherwise - """ - raise NotImplementedError - - @abstractmethod - def setPrimary(self): - """ - Set monitor as the primary one. - - WARNING: Notice this can also change the monitor position, altering the whole monitors setup. - To properly handle this, use arrangeMonitors() instead. - """ - raise NotImplementedError - - @abstractmethod - def turnOn(self): - """ - Turn on or wakeup monitor if it was off or suspended (but not if it is detached). - """ - raise NotImplementedError - - @abstractmethod - def turnOff(self): - """ - Turn off monitor - - WARNING: - - Windows: - If monitor has no VCP MCCS support, it can not be addressed separately, so ALL monitors will be turned off. - To address a specific monitor, try using detach() method - - macOS: - Didn't find a way to programmatically turn off a given monitor. Use suspend instead. - """ - raise NotImplementedError - - @abstractmethod - def suspend(self): - """ - Suspend (standby) monitor - - WARNING: - - Windows: - If monitor has no VCP MCCS support, it can not be addressed separately, so ALL monitors will be suspended. - To address a specific monitor, try using detach() method - - Linux: - This method will suspend ALL monitors. - - macOS: - This method will suspend ALL monitors. - """ - raise NotImplementedError - - @property - @abstractmethod - def isOn(self) -> Optional[bool]: - """ - Check if monitor is on - - WARNING: not working in macOS (... yet?) - """ - raise NotImplementedError - - @abstractmethod - def attach(self): - """ - Attach a previously detached monitor to system - - All monitor IDs will change after detaching or attaching a monitor. The module will try to refresh them for - all existing instances - - WARNING: not working in macOS (... yet?) - """ - raise NotImplementedError - - @abstractmethod - def detach(self, permanent: bool = False): - """ - Detach monitor from system. - - Be aware that if you detach a monitor and the script ends, you will have to physically re-attach the monitor. - - All monitor IDs will change after detaching or attaching a monitor. The module will try to refresh them for - all existing instances - - It will not likely work if system has just one monitor plugged. - - WARNING: not working in macOS (... yet?) - """ - raise NotImplementedError - - @property - @abstractmethod - def isAttached(self) -> Optional[bool]: - """ - Check if monitor is attached (not necessarily ON) to system - """ - raise NotImplementedError - - -_updateRequested = False -_plugListeners: List[Callable[[List[str], dict[str, ScreenValue]], None]] = [] -_lockPlug = threading.RLock() -_changeListeners: List[Callable[[List[str], dict[str, ScreenValue]], None]] = [] -_lockChange = threading.RLock() -_kill = threading.Event() - - -class _UpdateScreens(threading.Thread): - - def __init__(self, kill: threading.Event): - threading.Thread.__init__(self) - - self._kill = kill - self._interval = 0.5 - self._screens: dict[str, ScreenValue] = _getAllMonitorsDict() - self._monitors: list[Monitor] = [] - - def run(self): - - # _eventLoop(self._kill, self._interval) - - global _updateRequested - global _plugListeners - global _changeListeners - - while not self._kill.is_set(): - - if _updateRequested or _plugListeners or _changeListeners: - - screens = _getAllMonitorsDict() - newScreens = list(screens.keys()) - currentScreens = list(self._screens.keys()) - - if currentScreens != newScreens: - names = [s for s in newScreens if s not in currentScreens] + \ - [s for s in currentScreens if s not in newScreens] - for listener in _plugListeners: - listener(names, screens) - - if self._screens != screens: - names = [s for s in newScreens if s in currentScreens and screens[s] != self._screens[s]] - self._screens = screens - if names: - for listener in _changeListeners: - listener(names, screens) - - self._monitors = _getAllMonitors() - - self._kill.wait(self._interval) - - def updateInterval(self, interval: float): - self._interval = interval - - def getScreens(self) -> dict[str, ScreenValue]: - return self._screens - - def getMonitors(self) -> list[Monitor]: - return self._monitors - - -_updateScreens: Optional[_UpdateScreens] = None -_lockUpdate = threading.RLock() - - -def enableUpdateInfo(): - """ - Enable this only if you need to keep track of monitor-related events like changing its resolution, position, - or if monitors can be dynamically plugged or unplugged in a multi-monitor setup. This function can also be - useful in scenarios in which monitors list or properties need to be queried quickly and repeatedly, thus keeping - this information updated without impacting main process. - - If enabled, it will activate a separate thread which will periodically update the list of monitors and - their properties (see getAllMonitors() and getAllMonitorsDict() functions). - - If disabled, the information on the monitors connected to the system will be updated right at the moment, - but this might be slow and CPU-consuming, especially if quickly and repeatedly invoked. - """ - global _updateRequested - _updateRequested = True - if _updateScreens is None: - _startUpdateScreens() - - -def disableUpdateInfo(): - """ - The monitors information will be immediately queried after disabling this feature, not taking advantage of - keeping information updated on a separate thread. - - Enable this process again, or invoke getMonitors() function if you need updated info. - """ - global _updateRequested - _updateRequested = False - if not _plugListeners and not _changeListeners and not _updateRequested: - _killUpdateScreens() - - -def plugListenerRegister(monitorCountChanged: Callable[[List[str], dict[str, ScreenValue]], None]): - """ - Use this only if you need to keep track of monitor that can be dynamically plugged or unplugged in a - multi-monitor setup. - - The registered callbacks will be invoked in case the number of connected monitors change. - The information passed to the callbacks is: - - - Names of the screens which have changed (as a list of strings). - - All screens info, as returned by getAllMonitorsDict() function. - - It is possible to access all monitors information by using screen name as dictionary key - - :param monitorCountChanged: callback to be invoked in case the number of monitor connected changes - """ - global _plugListeners - global _lockPlug - with _lockPlug: - if monitorCountChanged not in _plugListeners: - _plugListeners.append(monitorCountChanged) - if _updateScreens is None: - _startUpdateScreens() - - -def plugListenerUnregister(monitorCountChanged: Callable[[List[str], dict[str, ScreenValue]], None]): - """ - Use this function to un-register your custom callback. The callback will not be invoked anymore in case - the number of monitor changes. - - :param monitorCountChanged: callback previously registered - """ - global _plugListeners - global _lockPlug - with _lockPlug: - try: - objIndex = _plugListeners.index(monitorCountChanged) - _plugListeners.pop(objIndex) - except: - pass - if not _plugListeners and not _changeListeners and not _updateRequested: - _killUpdateScreens() - - -def changeListenerRegister(monitorPropsChanged: Callable[[List[str], dict[str, ScreenValue]], None]): - """ - Use this only if you need to keep track of monitor properties changes (position, size, refresh-rate, etc.) in a - multi-monitor setup. - - The registered callbacks will be invoked in case these properties change. - The information passed to the callbacks is: - - - Names of the screens which have changed (as a list of strings). - - All screens info, as returned by getAllMonitorsDict() function. - - It is possible to access all monitor information by using screen name as dictionary key - - :param monitorPropsChanged: callback to be invoked in case the number of monitor properties change - """ - global _changeListeners - global _lockChange - with _lockChange: - if monitorPropsChanged not in _changeListeners: - _changeListeners.append(monitorPropsChanged) - if _updateScreens is None: - _startUpdateScreens() - - -def changeListenerUnregister(monitorPropsChanged: Callable[[List[str], dict[str, ScreenValue]], None]): - """ - Use this function to un-register your custom callback. The callback will not be invoked anymore in case - the monitor properties change. - - :param monitorPropsChanged: callback previously registered - """ - global _changeListeners - global _lockChange - with _lockChange: - try: - objIndex = _plugListeners.index(monitorPropsChanged) - _plugListeners.pop(objIndex) - except: - pass - if not _plugListeners and not _changeListeners and not _updateRequested: - _killUpdateScreens() - - -def _startUpdateScreens(): - global _updateScreens - global _lockUpdate - with _lockUpdate: - if _updateScreens is None: - _kill.clear() - _updateScreens = _UpdateScreens(_kill) - _updateScreens.daemon = True - _updateScreens.start() - - -def _killUpdateScreens(): - global _updateScreens - global _lockUpdate - global _kill - with _lockUpdate: - if _updateScreens is not None: - timer = threading.Timer(_updateScreens._interval * 2, _timerHandler) - timer.start() - try: - _kill.set() - _updateScreens.join(_updateScreens._interval * 3) - except: - pass - _updateScreens = None - timer.cancel() - - -class _TimeOutException(Exception): - pass - - -def _timerHandler(): - global _updateScreens - raise _TimeOutException() - - -def isWatchdogEnabled() -> bool: - """ - Check if the daemon updating screens information and (if applies) invoking callbacks when needed is alive. - - If it is not, just enable update process, or register the callbacks you need. It will be automatically started. - - :return: Return ''True'' is process (thread) is alive - """ - global _updateScreens - return bool(_updateScreens is not None) - - -def isUpdateInfoEnabled() -> bool: - """ - Get monitors watch process status (enabled / disabled). - - :return: Returns ''True'' if enabled. - """ - global _updateRequested - return _updateRequested - - -def isPlugListenerRegistered(monitorCountChanged: Callable[[List[str], dict[str, ScreenValue]], None]): - """ - Check if callback is already registered to be invoked when monitor plugged count change - - :return: Returns ''True'' if registered - """ - global _plugListeners - return monitorCountChanged in _plugListeners - - -def isChangeListenerRegistered(monitorPropsChanged: Callable[[List[str], dict[str, ScreenValue]], None]): - """ - Check if callback is already registered to be invoked when monitor properties change - - :return: Returns ''True'' if registered - """ - global _changeListeners - return monitorPropsChanged in _changeListeners - - -def updateWatchdogInterval(interval: float): - """ - Change the wait interval for the thread loop in seconds (or fractions), Default is 0.50 seconds. - - Higher values will take longer to detect and notify changes. - - Lower values will make it faster, but will consume more CPU. - - Also bear in mind that the OS will take some time to refresh changes, so lowering the update interval - may not necessarily produce better (faster) results. - - :param interval: new interval value in seconds (or fractions), as float. - """ - global _updateScreens - if interval > 0 and _updateScreens is not None: - _updateScreens.updateInterval(interval) - - -def _getRelativePosition(monitor, relativeTo) -> Tuple[int, int]: - relPos = monitor["relativePos"] - if relPos == Position.PRIMARY: - x = y = 0 - elif relPos == Position.LEFT_TOP: - x = relativeTo["position"].x - monitor["size"].width - y = relativeTo["position"].y - elif relPos == Position.LEFT_BOTTOM: - x = relativeTo["position"].x - monitor["size"].width - y = relativeTo["position"].y + relativeTo["size"].height - monitor["size"].height - elif relPos == Position.ABOVE_LEFT: - x = relativeTo["position"].x - y = relativeTo["position"].y - monitor["size"].height - elif relPos == Position.ABOVE_RIGHT: - x = relativeTo["position"].x + relativeTo["size"].width - monitor["size"].width - y = relativeTo["position"].y - monitor["size"].height - elif relPos == Position.RIGHT_TOP: - x = relativeTo["position"].x + relativeTo["size"].width - y = relativeTo["position"].y - elif relPos == Position.RIGHT_BOTTOM: - x = relativeTo["position"].x + relativeTo["size"].width - y = relativeTo["position"].y + relativeTo["size"].height - monitor["size"].height - elif relPos == Position.BELOW_LEFT: - x = relativeTo["position"].x - y = relativeTo["position"].y + relativeTo["size"].height - elif relPos == Position.BELOW_RIGHT: - x = relativeTo["position"].x + relativeTo["size"].width - monitor["size"].width - y = relativeTo["position"].y + relativeTo["size"].height - else: - x = y = monitor["position"] - return x, y - - -if sys.platform == "darwin": - from ._pymonctl_macos import (_getAllMonitors, _getAllMonitorsDict, _getMonitorsCount, _getPrimary, - _findMonitor, _arrangeMonitors, _getMousePos, MacOSMonitor as Monitor - ) -elif sys.platform == "win32": - from ._pymonctl_win import (_getAllMonitors, _getAllMonitorsDict, _getMonitorsCount, _getPrimary, - _findMonitor, _arrangeMonitors, _getMousePos, Win32Monitor as Monitor - ) -elif sys.platform == "linux": - from ._pymonctl_linux import (_getAllMonitors, _getAllMonitorsDict, _getMonitorsCount, _getPrimary, - _findMonitor, _arrangeMonitors, _getMousePos, LinuxMonitor as Monitor - ) -else: - raise NotImplementedError('PyMonCtl currently does not support this platform. If you think you can help, please contribute! https://github.com/Kalmat/PyMonCtl') +from ._main import (getAllMonitors, getAllMonitorsDict, getMonitorsCount, getPrimary, findMonitor, findMonitorInfo, + arrangeMonitors, getMousePos, Monitor, + enableUpdateInfo, disableUpdateInfo, isUpdateInfoEnabled, isWatchdogEnabled, updateWatchdogInterval, + plugListenerRegister, plugListenerUnregister, isPlugListenerRegistered, + changeListenerRegister, changeListenerUnregister, isChangeListenerRegistered, + DisplayMode, ScreenValue, Size, Point, Box, Rect, Position, Orientation + ) diff --git a/src/pymonctl/_main.py b/src/pymonctl/_main.py new file mode 100644 index 0000000..1ddbca0 --- /dev/null +++ b/src/pymonctl/_main.py @@ -0,0 +1,853 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +from __future__ import annotations + +import sys +import threading +from abc import abstractmethod, ABC +from collections.abc import Callable +from typing import List, Optional, Union, Tuple + +from pymonctl._structs import DisplayMode, ScreenValue, Size, Point, Box, Rect, Position, Orientation + + +def _pointInBox(x: int, y: int, left: int, top: int, width: int, height: int) -> bool: + """Returns ``True`` if the ``(x, y)`` point is within the box described + by ``(left, top, width, height)``.""" + return left <= x <= left + width and top <= y <= top + height + + +def getAllMonitors() -> list[Monitor]: + """ + Get the list with all Monitor instances from plugged monitors. + + In case you plan to use this function in a scenario in which it could be invoked quickly and repeatedly, + it's highly recommended to enable update watchdog (see enableUpdate() function). + + :return: list of Monitor instances + """ + global _updateScreens + if _updateScreens is None: + return _getAllMonitors() + else: + return _updateScreens.getMonitors() + + +def getAllMonitorsDict() -> dict[str, ScreenValue]: + """ + Get all monitors info plugged to the system, as a dict. + + In case you plan to use this function in a scenario in which it could be invoked quickly and repeatedly, + it's highly recommended to enable update watchdog (see enableUpdate() function). + + :return: Monitors info as python dictionary + + Output Format: + Key: + Display name (in macOS it is necessary to add handle to avoid duplicates) + + Values: + "system_name": + display name as returned by the system (in macOS, the name can be duplicated!) + "handle": + display handle according to each platform/OS + "is_primary": + ''True'' if monitor is primary (shows clock and notification area, sign in, lock, CTRL+ALT+DELETE screens...) + "position": + Point(x, y) struct containing the display position ((0, 0) for the primary screen) + "size": + Size(width, height) struct containing the display size, in pixels + "workarea": + Rect(left, top, right, bottom) struct with the screen workarea, in pixels + "scale": + Scale ratio, as a tuple of (x, y) scale percentage + "dpi": + Dots per inch, as a tuple of (x, y) dpi values + "orientation": + Display orientation: 0 - Landscape / 1 - Portrait / 2 - Landscape (reversed) / 3 - Portrait (reversed) + "frequency": + Refresh rate of the display, in Hz + "colordepth": + Bits per pixel referred to the display color depth + """ + global _updateScreens + if _updateScreens is None: + return _getAllMonitorsDict() + else: + return _updateScreens.getScreens() + + +def getMonitorsCount() -> int: + """ + Get the number of monitors currently connected to the system. + + :return: number of monitors as integer + """ + return _getMonitorsCount() + + +def getPrimary() -> Monitor: + """ + Get primary monitor instance. This is equivalent to invoking ''Monitor()'', with empty input params. + + :return: Monitor instance or None + """ + return _getPrimary() + + +def findMonitor(x: int, y: int) -> Optional[List[Monitor]]: + """ + Get monitor instance in which given coordinates (x, y) are found. + + :return: Monitor instance or None + """ + return _findMonitor(x, y) + + +def findMonitorInfo(x: int, y: int) -> dict[str, ScreenValue]: + """ + Get monitor info in which given coordinates (x, y) are found. + + :return: monitor info (see getAllMonitorsDict() doc) as dictionary, or empty + """ + info: dict[str, ScreenValue] = {} + monitors = getAllMonitorsDict() + for monitor in monitors.keys(): + pos = monitors[monitor]["position"] + size = monitors[monitor]["size"] + if _pointInBox(x, y, pos.x, pos.y, size.width, size.height): + info[monitor] = monitors[monitor] + return info + + +def arrangeMonitors(arrangement: dict[str, dict[str, Union[str, int, Position, Point, Size]]]): + """ + Arrange all monitors in a given shape. + + For that, you must pass a dict with the following structure: + "Monitor name": + monitor name as keys() returned by getAllMonitorsDict() (don't use "system_name" value for this) + "relativePos": + position of this monitor in relation to the monitor provided in ''relativeTo'' + "relativeTo": + monitor name to which ''relativePos'' is referred to (or None if PRIMARY) + + + You MUST pass the position of ALL monitors, and SET ONE of them as PRIMARY. + + HIGHLY RECOMMENDED: When building complex arrangements, start by the primary monitor and then build the rest + taking previous ones as references. + + EXAMPLE for a 3-Monitors setup in which second is at the left and third is on top of primary monitor: + + { + "Display_1": {"relativePos": Position.PRIMARY, "relativeTo": None}, + + "Display_2": {"relativePos": Position.LEFT_TOP, "relativeTo": "Display_1"}, + + "Display_3": {"relativePos": Position.ABOVE_LEFT, "relativeTo": "Display_1"} + } + + :param arrangement: arrangement structure as dict + """ + _arrangeMonitors(arrangement) + + +def getMousePos() -> Point: + """ + Get the current (x, y) coordinates of the mouse pointer on screen, in pixels + + :return: Point struct + """ + return _getMousePos() + + +class BaseMonitor(ABC): + + @property + @abstractmethod + def size(self) -> Optional[Size]: + """ + Get the dimensions of the monitor as a size struct (width, height) + + This property can not be set independently. To do so, choose an allowed mode (from monitor.allModes) + and set the monitor mode property (monitor.mode = selectedMode) + + :return: Size + """ + raise NotImplementedError + + @property + @abstractmethod + def workarea(self) -> Optional[Rect]: + """ + Get dimensions of the "usable by applications" area (screen size minus docks, taskbars and so on), as + a rect struct (x, y, right, bottom) + + This property can not be set. + + :return: Rect + """ + raise NotImplementedError + + @property + @abstractmethod + def position(self) -> Optional[Point]: + """ + Get monitor position coordinates as a point struct (x, y) + + This property can not be set. Use setPosition() method instead. + + :return: Point + """ + raise NotImplementedError + + @abstractmethod + def setPosition(self, relativePos: Union[int, Position], relativeTo: Optional[str]): + """ + Change relative position of the current the monitor in relation to another existing monitor (e.g. primary monitor). + + In general, it is HIGHLY recommendable to use arrangeMonitors() method instead of setPosition(), and most + especially in complex arrangements or setups with more than 2 monitors. + + Important issues: + + - On Windows, primary monitor is mandatory, and it is always placed at (0, 0) coordinates. Besides, the monitors can not overlap. In case the monitor you want to reposition is the primary or the unique one, it will have no effect. To do so, you must switch the primary monitor first, then reposition it. + + - On Linux, primary monitor can be anywhere, monitors can overlap and even there can be no primary monitor + + - On macOS, primary monitor is mandatory, and it is always placed at (0, 0) coordinates. You will likely have to reposition primary monitor before setting a different monitor as primary. Monitors can overlap. Further tests in multi-monitor setups are still required to confirm these behaviors and produce a final version + + :param relativePos: position in relation to another existing monitor (e.g. primary) as per Positions.* + :param relativeTo: monitor in relation to which this monitor must be placed + """ + raise NotImplementedError + + @property + @abstractmethod + def box(self) -> Optional[Box]: + """ + Get monitor dimensions as a box struct (x, y, width, height) + + This property can not be set. + + :return: Box + """ + raise NotImplementedError + + @property + @abstractmethod + def rect(self) -> Optional[Rect]: + """ + Get monitor dimensions as a rect struct (x, y, right, bottom) + + This property can not be set. + + :return: Rect + """ + raise NotImplementedError + + @property + @abstractmethod + def scale(self) -> Optional[Tuple[float, float]]: + """ + Get scale for the monitor + + Note not all scales will be allowed for all monitors and/or modes + """ + raise NotImplementedError + + @abstractmethod + def setScale(self, scale: Tuple[float, float]): + """ + Change scale for the monitor + + Note not all scales will be allowed for all monitors and/or modes + + :param scale: target percentage as float value + """ + raise NotImplementedError + + @property + @abstractmethod + def dpi(self) -> Optional[Tuple[float, float]]: + """ + Get the dpi (dots/pixels per inch) value for the monitor + + This property can not be set + """ + raise NotImplementedError + + @property + @abstractmethod + def orientation(self) -> Optional[Union[int, Orientation]]: + """ + Get current orientation for the monitor identified by name (or primary if empty) + + The available orientations are: + 0 - 0 degrees (normal) + 1 - 90 degrees (right) + 2 - 180 degrees (inverted) + 3 - 270 degrees (left) + """ + raise NotImplementedError + + @abstractmethod + def setOrientation(self, orientation: Optional[Union[int, Orientation]]): + """ + Change orientation for the monitor identified by name (or primary if empty) + + The available orientations are: + 0 - 0 degrees (normal) + 1 - 90 degrees (right) + 2 - 180 degrees (inverted) + 3 - 270 degrees (left) + + :param orientation: orientation as per Orientations.* + """ + raise NotImplementedError + + @property + @abstractmethod + def frequency(self) -> Optional[float]: + """ + Get current refresh rate of monitor. + + This property can not be set independently. To do so, choose an allowed mode (from monitor.allModes) + and set the monitor mode property (monitor.mode = selectedMode) + """ + raise NotImplementedError + + @property + @abstractmethod + def colordepth(self) -> Optional[int]: + """ + Get the colordepth (bits per pixel to describe color) value for the monitor + + This property can not be set + """ + raise NotImplementedError + + @property + @abstractmethod + def brightness(self) -> Optional[int]: + """ + Get the brightness of monitor. The return value is normalized to 0-100 (as a percentage) + + :return: brightness as float + """ + raise NotImplementedError + + @abstractmethod + def setBrightness(self, brightness: Optional[int]): + """ + Change the brightness of monitor. The input parameter must be defined as a percentage (0-100) + """ + raise NotImplementedError + + @property + @abstractmethod + def contrast(self) -> Optional[int]: + """ + Get the contrast of monitor. The return value is normalized to 0-100 (as a percentage) + + WARNING: In Linux and macOS contrast is calculated from Gamma RGB values. + + :return: contrast as float + """ + raise NotImplementedError + + @abstractmethod + def setContrast(self, contrast: Optional[int]): + """ + Change the contrast of monitor. The input parameter must be defined as a percentage (0-100) + + WARNING: In Linux and macOS the change will apply to Gamma homogeneously for all color components (R, G, B). + + Example for Linux: A value of 50.0 (50%), will result in a Gamma of ''0.5:0.5:0.5'' + """ + raise NotImplementedError + + @property + @abstractmethod + def mode(self) -> Optional[DisplayMode]: + """ + Get the current monitor mode (width, height, refresh-rate) for the monitor + + :return: current mode as DisplayMode struct + """ + raise NotImplementedError + + @abstractmethod + def setMode(self, mode: Optional[DisplayMode]): + """ + Change current monitor mode (resolution and/or refresh-rate) for the monitor + + The mode must be one of the allowed modes by the monitor (see allModes property). + + :param mode: target mode as DisplayMode (width, height and frequency) + """ + raise NotImplementedError + + @property + @abstractmethod + def defaultMode(self) -> Optional[DisplayMode]: + """ + Get the preferred mode for the monitor + + :return: DisplayMode struct (width, height, frequency) + """ + raise NotImplementedError + + @abstractmethod + def setDefaultMode(self): + """ + Change current mode to default / preferred mode + """ + raise NotImplementedError + + @property + @abstractmethod + def allModes(self) -> list[DisplayMode]: + """ + Get all allowed modes for the monitor + + :return: list of DisplayMode (width, height, frequency) + """ + raise NotImplementedError + + @property + def isPrimary(self) -> bool: + """ + Check if given monitor is primary. + + :return: ''True'' if given monitor is primary, ''False'' otherwise + """ + raise NotImplementedError + + @abstractmethod + def setPrimary(self): + """ + Set monitor as the primary one. + + WARNING: Notice this can also change the monitor position, altering the whole monitors setup. + To properly handle this, use arrangeMonitors() instead. + """ + raise NotImplementedError + + @abstractmethod + def turnOn(self): + """ + Turn on or wakeup monitor if it was off or suspended (but not if it is detached). + """ + raise NotImplementedError + + @abstractmethod + def turnOff(self): + """ + Turn off monitor + + WARNING: + + Windows: + If monitor has no VCP MCCS support, it can not be addressed separately, so ALL monitors will be turned off. + To address a specific monitor, try using detach() method + + macOS: + Didn't find a way to programmatically turn off a given monitor. Use suspend instead. + """ + raise NotImplementedError + + @abstractmethod + def suspend(self): + """ + Suspend (standby) monitor + + WARNING: + + Windows: + If monitor has no VCP MCCS support, it can not be addressed separately, so ALL monitors will be suspended. + To address a specific monitor, try using detach() method + + Linux: + This method will suspend ALL monitors. + + macOS: + This method will suspend ALL monitors. + """ + raise NotImplementedError + + @property + @abstractmethod + def isOn(self) -> Optional[bool]: + """ + Check if monitor is on + + WARNING: not working in macOS (... yet?) + """ + raise NotImplementedError + + @abstractmethod + def attach(self): + """ + Attach a previously detached monitor to system + + All monitor IDs will change after detaching or attaching a monitor. The module will try to refresh them for + all existing instances + + WARNING: not working in macOS (... yet?) + """ + raise NotImplementedError + + @abstractmethod + def detach(self, permanent: bool = False): + """ + Detach monitor from system. + + Be aware that if you detach a monitor and the script ends, you will have to physically re-attach the monitor. + + All monitor IDs will change after detaching or attaching a monitor. The module will try to refresh them for + all existing instances + + It will not likely work if system has just one monitor plugged. + + WARNING: not working in macOS (... yet?) + """ + raise NotImplementedError + + @property + @abstractmethod + def isAttached(self) -> Optional[bool]: + """ + Check if monitor is attached (not necessarily ON) to system + """ + raise NotImplementedError + + +_updateRequested = False +_plugListeners: List[Callable[[List[str], dict[str, ScreenValue]], None]] = [] +_lockPlug = threading.RLock() +_changeListeners: List[Callable[[List[str], dict[str, ScreenValue]], None]] = [] +_lockChange = threading.RLock() +_kill = threading.Event() + + +class _UpdateScreens(threading.Thread): + + def __init__(self, kill: threading.Event): + threading.Thread.__init__(self) + + self._kill = kill + self._interval = 0.5 + self._screens: dict[str, ScreenValue] = _getAllMonitorsDict() + self._monitors: list[Monitor] = [] + + def run(self): + + # _eventLoop(self._kill, self._interval) + + global _updateRequested + global _plugListeners + global _changeListeners + + while not self._kill.is_set(): + + if _updateRequested or _plugListeners or _changeListeners: + + screens = _getAllMonitorsDict() + newScreens = list(screens.keys()) + currentScreens = list(self._screens.keys()) + + if currentScreens != newScreens: + names = [s for s in newScreens if s not in currentScreens] + \ + [s for s in currentScreens if s not in newScreens] + for listener in _plugListeners: + listener(names, screens) + + if self._screens != screens: + names = [s for s in newScreens if s in currentScreens and screens[s] != self._screens[s]] + self._screens = screens + if names: + for listener in _changeListeners: + listener(names, screens) + + self._monitors = _getAllMonitors() + + self._kill.wait(self._interval) + + def updateInterval(self, interval: float): + self._interval = interval + + def getScreens(self) -> dict[str, ScreenValue]: + return self._screens + + def getMonitors(self) -> list[Monitor]: + return self._monitors + + +_updateScreens: Optional[_UpdateScreens] = None +_lockUpdate = threading.RLock() + + +def enableUpdateInfo(): + """ + Enable this only if you need to keep track of monitor-related events like changing its resolution, position, + or if monitors can be dynamically plugged or unplugged in a multi-monitor setup. This function can also be + useful in scenarios in which monitors list or properties need to be queried quickly and repeatedly, thus keeping + this information updated without impacting main process. + + If enabled, it will activate a separate thread which will periodically update the list of monitors and + their properties (see getAllMonitors() and getAllMonitorsDict() functions). + + If disabled, the information on the monitors connected to the system will be updated right at the moment, + but this might be slow and CPU-consuming, especially if quickly and repeatedly invoked. + """ + global _updateRequested + _updateRequested = True + if _updateScreens is None: + _startUpdateScreens() + + +def disableUpdateInfo(): + """ + The monitors information will be immediately queried after disabling this feature, not taking advantage of + keeping information updated on a separate thread. + + Enable this process again, or invoke getMonitors() function if you need updated info. + """ + global _updateRequested + _updateRequested = False + if not _plugListeners and not _changeListeners and not _updateRequested: + _killUpdateScreens() + + +def plugListenerRegister(monitorCountChanged: Callable[[List[str], dict[str, ScreenValue]], None]): + """ + Use this only if you need to keep track of monitor that can be dynamically plugged or unplugged in a + multi-monitor setup. + + The registered callbacks will be invoked in case the number of connected monitors change. + The information passed to the callbacks is: + + - Names of the screens which have changed (as a list of strings). + - All screens info, as returned by getAllMonitorsDict() function. + + It is possible to access all monitors information by using screen name as dictionary key + + :param monitorCountChanged: callback to be invoked in case the number of monitor connected changes + """ + global _plugListeners + global _lockPlug + with _lockPlug: + if monitorCountChanged not in _plugListeners: + _plugListeners.append(monitorCountChanged) + if _updateScreens is None: + _startUpdateScreens() + + +def plugListenerUnregister(monitorCountChanged: Callable[[List[str], dict[str, ScreenValue]], None]): + """ + Use this function to un-register your custom callback. The callback will not be invoked anymore in case + the number of monitor changes. + + :param monitorCountChanged: callback previously registered + """ + global _plugListeners + global _lockPlug + with _lockPlug: + try: + objIndex = _plugListeners.index(monitorCountChanged) + _plugListeners.pop(objIndex) + except: + pass + if not _plugListeners and not _changeListeners and not _updateRequested: + _killUpdateScreens() + + +def changeListenerRegister(monitorPropsChanged: Callable[[List[str], dict[str, ScreenValue]], None]): + """ + Use this only if you need to keep track of monitor properties changes (position, size, refresh-rate, etc.) in a + multi-monitor setup. + + The registered callbacks will be invoked in case these properties change. + The information passed to the callbacks is: + + - Names of the screens which have changed (as a list of strings). + - All screens info, as returned by getAllMonitorsDict() function. + + It is possible to access all monitor information by using screen name as dictionary key + + :param monitorPropsChanged: callback to be invoked in case the number of monitor properties change + """ + global _changeListeners + global _lockChange + with _lockChange: + if monitorPropsChanged not in _changeListeners: + _changeListeners.append(monitorPropsChanged) + if _updateScreens is None: + _startUpdateScreens() + + +def changeListenerUnregister(monitorPropsChanged: Callable[[List[str], dict[str, ScreenValue]], None]): + """ + Use this function to un-register your custom callback. The callback will not be invoked anymore in case + the monitor properties change. + + :param monitorPropsChanged: callback previously registered + """ + global _changeListeners + global _lockChange + with _lockChange: + try: + objIndex = _plugListeners.index(monitorPropsChanged) + _plugListeners.pop(objIndex) + except: + pass + if not _plugListeners and not _changeListeners and not _updateRequested: + _killUpdateScreens() + + +def _startUpdateScreens(): + global _updateScreens + global _lockUpdate + with _lockUpdate: + if _updateScreens is None: + _kill.clear() + _updateScreens = _UpdateScreens(_kill) + _updateScreens.daemon = True + _updateScreens.start() + + +def _killUpdateScreens(): + global _updateScreens + global _lockUpdate + global _kill + with _lockUpdate: + if _updateScreens is not None: + timer = threading.Timer(_updateScreens._interval * 2, _timerHandler) + timer.start() + try: + _kill.set() + _updateScreens.join(_updateScreens._interval * 3) + except: + pass + _updateScreens = None + timer.cancel() + + +class _TimeOutException(Exception): + pass + + +def _timerHandler(): + global _updateScreens + raise _TimeOutException() + + +def isWatchdogEnabled() -> bool: + """ + Check if the daemon updating screens information and (if applies) invoking callbacks when needed is alive. + + If it is not, just enable update process, or register the callbacks you need. It will be automatically started. + + :return: Return ''True'' is process (thread) is alive + """ + global _updateScreens + return bool(_updateScreens is not None) + + +def isUpdateInfoEnabled() -> bool: + """ + Get monitors watch process status (enabled / disabled). + + :return: Returns ''True'' if enabled. + """ + global _updateRequested + return _updateRequested + + +def isPlugListenerRegistered(monitorCountChanged: Callable[[List[str], dict[str, ScreenValue]], None]): + """ + Check if callback is already registered to be invoked when monitor plugged count change + + :return: Returns ''True'' if registered + """ + global _plugListeners + return monitorCountChanged in _plugListeners + + +def isChangeListenerRegistered(monitorPropsChanged: Callable[[List[str], dict[str, ScreenValue]], None]): + """ + Check if callback is already registered to be invoked when monitor properties change + + :return: Returns ''True'' if registered + """ + global _changeListeners + return monitorPropsChanged in _changeListeners + + +def updateWatchdogInterval(interval: float): + """ + Change the wait interval for the thread loop in seconds (or fractions), Default is 0.50 seconds. + + Higher values will take longer to detect and notify changes. + + Lower values will make it faster, but will consume more CPU. + + Also bear in mind that the OS will take some time to refresh changes, so lowering the update interval + may not necessarily produce better (faster) results. + + :param interval: new interval value in seconds (or fractions), as float. + """ + global _updateScreens + if interval > 0 and _updateScreens is not None: + _updateScreens.updateInterval(interval) + + +def _getRelativePosition(monitor, relativeTo) -> Tuple[int, int]: + relPos = monitor["relativePos"] + if relPos == Position.PRIMARY: + x = y = 0 + elif relPos == Position.LEFT_TOP: + x = relativeTo["position"].x - monitor["size"].width + y = relativeTo["position"].y + elif relPos == Position.LEFT_BOTTOM: + x = relativeTo["position"].x - monitor["size"].width + y = relativeTo["position"].y + relativeTo["size"].height - monitor["size"].height + elif relPos == Position.ABOVE_LEFT: + x = relativeTo["position"].x + y = relativeTo["position"].y - monitor["size"].height + elif relPos == Position.ABOVE_RIGHT: + x = relativeTo["position"].x + relativeTo["size"].width - monitor["size"].width + y = relativeTo["position"].y - monitor["size"].height + elif relPos == Position.RIGHT_TOP: + x = relativeTo["position"].x + relativeTo["size"].width + y = relativeTo["position"].y + elif relPos == Position.RIGHT_BOTTOM: + x = relativeTo["position"].x + relativeTo["size"].width + y = relativeTo["position"].y + relativeTo["size"].height - monitor["size"].height + elif relPos == Position.BELOW_LEFT: + x = relativeTo["position"].x + y = relativeTo["position"].y + relativeTo["size"].height + elif relPos == Position.BELOW_RIGHT: + x = relativeTo["position"].x + relativeTo["size"].width - monitor["size"].width + y = relativeTo["position"].y + relativeTo["size"].height + else: + x = y = monitor["position"] + return x, y + + +if sys.platform == "darwin": + from ._pymonctl_macos import (_getAllMonitors, _getAllMonitorsDict, _getMonitorsCount, _getPrimary, + _findMonitor, _arrangeMonitors, _getMousePos, MacOSMonitor as Monitor + ) +elif sys.platform == "win32": + from ._pymonctl_win import (_getAllMonitors, _getAllMonitorsDict, _getMonitorsCount, _getPrimary, + _findMonitor, _arrangeMonitors, _getMousePos, Win32Monitor as Monitor + ) +elif sys.platform == "linux": + from ._pymonctl_linux import (_getAllMonitors, _getAllMonitorsDict, _getMonitorsCount, _getPrimary, + _findMonitor, _arrangeMonitors, _getMousePos, LinuxMonitor as Monitor + ) +else: + raise NotImplementedError('PyMonCtl currently does not support this platform. If you think you can help, please contribute! https://github.com/Kalmat/PyMonCtl') diff --git a/src/pymonctl/_pymonctl_linux.py b/src/pymonctl/_pymonctl_linux.py index 9d86778..cd2882d 100644 --- a/src/pymonctl/_pymonctl_linux.py +++ b/src/pymonctl/_pymonctl_linux.py @@ -8,7 +8,6 @@ import subprocess import threading -import time import math from typing import Optional, List, Union, cast, Tuple @@ -19,12 +18,10 @@ import Xlib.xobject from Xlib.ext import randr -from pymonctl import BaseMonitor, _pointInBox, _getRelativePosition, \ - DisplayMode, ScreenValue, Box, Rect, Point, Size, Position, Orientation +from ._main import BaseMonitor, _pointInBox, _getRelativePosition, \ + DisplayMode, ScreenValue, Box, Rect, Point, Size, Position, Orientation from ewmhlib import displaysCount, getDisplaysNames, defaultDisplay, defaultRoot, defaultScreen, defaultRootWindow, \ - getProperty, getPropertyValue -from ewmhlib.Props import Root - + getProperty, getPropertyValue, Props # Check if randr extension is available if not defaultRootWindow.display.has_extension('RANDR'): @@ -76,10 +73,9 @@ def _XgetRoots(): _roots = _XgetRoots() -def _getAllMonitors(outputs=None) -> list[LinuxMonitor]: +def _getAllMonitors() -> list[LinuxMonitor]: monitors = [] - if not outputs: - outputs = _XgetAllOutputs() + outputs = _XgetAllOutputs() for outputData in outputs: display, screen, root, res, output, outputInfo = outputData if outputInfo.crtc: @@ -105,7 +101,7 @@ def _getAllMonitorsDict() -> dict[str, ScreenValue]: is_primary = monitor.primary == 1 x, y, w, h = monitor.x, monitor.y, monitor.width_in_pixels, monitor.height_in_pixels # https://askubuntu.com/questions/1124149/how-to-get-taskbar-size-and-position-with-python - wa: List[int] = getPropertyValue(getProperty(window=root, prop=Root.WORKAREA, display=display), display=display) + wa: List[int] = getPropertyValue(getProperty(window=root, prop=Props.Root.WORKAREA, display=display), display=display) wx, wy, wr, wb = wa[0], wa[1], wa[2], wa[3] dpiX, dpiY = round((w * 25.4) / (monitor.width_in_millimeters or 1)), round((h * 25.4) / (monitor.height_in_millimeters or 1)) scaleX, scaleY = _scale(monitorName) or (0.0, 0.0) @@ -206,11 +202,14 @@ def _arrangeMonitors(arrangement: dict[str, dict[str, Union[str, int, Position, if y < 0: yOffset += abs(y) newPos[monName] = {"x": x, "y": y} + w, h = targetMonInfo.width_in_pixels, targetMonInfo.height_in_pixels newArrangement[monName] = { "setPrimary": relativePos == Position.PRIMARY, "x": x, - "y": y + "y": y, + "w": w, + "h": h } if newArrangement: @@ -262,7 +261,7 @@ def workarea(self) -> Optional[Rect]: res: Optional[Rect] = None # https://askubuntu.com/questions/1124149/how-to-get-taskbar-size-and-position-with-python wa: List[int] = getPropertyValue( - getProperty(window=self.root, prop=Root.WORKAREA, display=self.display), display=self.display) + getProperty(window=self.root, prop=Props.Root.WORKAREA, display=self.display), display=self.display) if wa: wx, wy, wr, wb = wa[0], wa[1], wa[2], wa[3] res = Rect(wx, wy, wr, wb) @@ -303,15 +302,18 @@ def scale(self) -> Optional[Tuple[float, float]]: return _scale(self.name) def setScale(self, scale: Optional[Tuple[float, float]]): - if scale is not None and self.name and self.name in _XgetAllMonitorsNames(): + if scale is not None: # https://askubuntu.com/questions/1193940/setting-monitor-scaling-to-200-with-xrandr - scaleX, scaleY = round(100/ scale[0], 1), round(100 / scale[1], 1) - # cmd = "xrandr --output %s --scale %sx%s --filter nearest" % (self.name, scaleX, scaleY) - cmd = "xrandr --output %s --scale %sx%s" % (self.name, scaleX, scaleY) - try: - subprocess.run(cmd, shell=True, stdout=subprocess.DEVNULL, timeout=1) - except: - pass + scaleX, scaleY = round(100 / scale[0], 1), round(100 / scale[1], 1) + if 0 < scaleX <= 1 and 0 < scaleY <= 1: + cmd = "xrandr --output %s --scale %sx%s --filter nearest" % (self.name, scaleX, scaleY) + try: + ret = subprocess.run(cmd, shell=True, stdout=subprocess.DEVNULL, timeout=1) + if ret and hasattr(ret, "returncode") and ret.returncode != 0: + cmd = "xrandr --output %s --scale %sx%s" % (self.name, scaleX, scaleY) + subprocess.run(cmd, shell=True, stdout=subprocess.DEVNULL, timeout=1) + except: + pass @property def dpi(self) -> Optional[Tuple[float, float]]: @@ -464,42 +466,11 @@ def setMode(self, mode: Optional[DisplayMode]): # Xlib.ext.randr.set_screen_config(defaultRootWindow.root, size_id, 0, 0, round(mode.frequency), 0) # Xlib.ext.randr.change_output_property() if mode is not None: - # allModes = self.allModes - # if mode in allModes: - cmd = " --mode %sx%s -r %s" % (mode.width, mode.height, round(mode.frequency, 2)) - if self.name: - cmd = (" --output %s" % self.name) + cmd - cmd = "xrandr" + cmd - i = 0 - while mode != self.mode and i <= 3: - try: - subprocess.run(cmd, shell=True, stdout=subprocess.DEVNULL, timeout=1) - except: - pass - i += 1 - time.sleep(0.3) - - def _modeB(self) -> Optional[DisplayMode]: - - outMode: Optional[DisplayMode] = None - allModes = [] - mode = None - - for crtc in _XgetAllCrtcs(self.name): - res = crtc[3] - crtcInfo = crtc[7] - if crtcInfo.mode: - mode = crtcInfo.mode - allModes = res.modes - break - - if mode and allModes: - for m in allModes: - if mode == m.id: - outMode = DisplayMode(m.width, m.height, - round(m.dot_clock / ((m.h_total * m.v_total) or 1), 2)) - break - return outMode + cmd = "xrandr --output %s --mode %sx%s -r %s" % (self.name, mode.width, mode.height, round(mode.frequency, 2)) + try: + subprocess.run(cmd, shell=True, stdout=subprocess.DEVNULL, timeout=1) + except: + pass @property def defaultMode(self) -> Optional[DisplayMode]: @@ -521,20 +492,24 @@ def defaultMode(self) -> Optional[DisplayMode]: def setDefaultMode(self): cmd = "xrandr --output %s --auto" % self.name - _, _ = subprocess.getstatusoutput(cmd) + try: + subprocess.run(cmd, shell=True, stdout=subprocess.DEVNULL, timeout=1) + except: + pass @property def allModes(self) -> list[DisplayMode]: modes: List[DisplayMode] = [] - allModes = [] - for crtcData in _XgetAllCrtcs(self.name): - display, screen, root, res, output, outputInfo, crtc, crtcInfo = crtcData - if crtcInfo.mode: - allModes = res.modes + for outputData in _XgetAllOutputs(self.name): + display, screen, root, res, output, outputInfo = outputData + if self.handle == output: + for outMode in outputInfo.modes: + for resMode in res.modes: + if outMode == resMode.id: + modes.append(DisplayMode(resMode.width, resMode.height, + round(resMode.dot_clock / ((resMode.h_total * resMode.v_total) or 1), 2))) + break - for mode in allModes: - modes.append(DisplayMode(mode.width, mode.height, - round(mode.dot_clock / ((mode.h_total * mode.v_total) or 1), 2))) return modes @property @@ -555,14 +530,10 @@ def turnOn(self): cmdPart = " --right-of %s" % monName break cmd = ("xrandr --output %s" % self.name) + cmdPart + " --auto" - i = 0 - while i <= 3 and not self.isOn: - try: - subprocess.run(cmd, shell=True, stdout=subprocess.DEVNULL, timeout=1) - except: - pass - i += 1 - time.sleep(0.3) + try: + subprocess.run(cmd, shell=True, stdout=subprocess.DEVNULL, timeout=1) + except: + pass else: cmd = "xset -q | grep ' Monitor is ' | awk '{ print$4 }'" try: @@ -591,7 +562,15 @@ def suspend(self): @property def isOn(self) -> bool: - return self.name in _XgetAllMonitorsNames() + # https://stackoverflow.com/questions/3433203/how-to-determine-if-lcd-monitor-is-turned-on-from-linux-command-line + cmd = "xrandr --listactivemonitors" + try: + err, ret = subprocess.getstatusoutput(cmd) + if err == 0: + return self.name in ret + except: + pass + return False def attach(self): # This produces the same effect, but requires to keep track of last mode used @@ -679,11 +658,12 @@ def _setPosition(relativePos: Position, relativeTo: Optional[str], name: str): w, h = targetMonInfo.width_in_pixels, targetMonInfo.height_in_pixels setPrimary = targetMonInfo.primary == 1 - newPos[monitor] = {"x": x, "y": y} + newPos[monitor] = {"x": x, "y": y, "w": w, "h": h} else: monInfo = monitors[monitor]["monitor"] x, y = monInfo.x, monInfo.y + w, h = monInfo.width_in_pixels, monInfo.height_in_pixels setPrimary = monInfo.primary == 1 if x < 0: xOffset += abs(x) @@ -693,7 +673,9 @@ def _setPosition(relativePos: Position, relativeTo: Optional[str], name: str): arrangement[monitor] = { "setPrimary": setPrimary, "x": x, - "y": y + "y": y, + "w": w, + "h": h } if arrangement: @@ -712,9 +694,9 @@ def _buildCommand(arrangement: dict[str, dict[str, Union[int, bool]]], xOffset: # xrandr won't accept negative values!!!! # https://superuser.com/questions/485120/how-do-i-align-the-bottom-edges-of-two-monitors-with-xrandr cmd += " --pos %sx%s" % (str(int(arrInfo["x"]) + xOffset), str(int(arrInfo["y"]) + yOffset)) + cmd += " --mode %sx%s" % (arrInfo["w"], arrInfo["h"]) if arrInfo["setPrimary"]: cmd += " --primary" - print(cmd) return cmd @@ -748,44 +730,20 @@ def _scale(name: str = "") -> Optional[Tuple[float, float]]: return None -_outputs = [] -_lockOutputs = threading.RLock() -_monitors = [] # type: ignore[var-annotated] -_lockMonitors = threading.RLock() - - def _XgetAllOutputs(name: str = ""): - global _outputs - global _lockOutputs - global _monitors - global _lockMonitors - newMonitors = _XgetAllMonitors() - if _monitors != newMonitors: - with _lockMonitors: - _monitors = newMonitors - outputs = [] - global _roots - for rootData in _roots: - display, screen, root, res = rootData - if res: - for output in res.outputs: - try: - outputInfo = randr.get_output_info(display, output, res.config_timestamp) + outputs = [] + global _roots + for rootData in _roots: + display, screen, root, res = rootData + if res: + for output in res.outputs: + try: + outputInfo = randr.get_output_info(display, output, res.config_timestamp) + if not name or (name and name == outputInfo.name): outputs.append([display, screen, root, res, output, outputInfo]) - except: - pass - with _lockOutputs: - _outputs = outputs - if name: - ret = [] - for outputData in _outputs: - display, screen, root, res, output, outputInfo = outputData - if name == outputInfo.name: - ret.append(outputData) - break - return ret - else: - return _outputs + except: + pass + return outputs def _XgetAllCrtcs(name: str = ""): @@ -853,18 +811,17 @@ def _XgetPrimary(): def _XgetMonitorData(handle: Optional[int] = None): + outputs = _XgetAllOutputs() if handle: - outputs = _XgetAllOutputs() for outputData in outputs: display, screen, root, res, output, outputInfo = outputData if output == handle: return display, screen, root, res, output, outputInfo.name else: - outputs = _XgetAllOutputs() - global _monitors - for monitorData in _monitors: + monitors = _XgetAllMonitors() + for monitorData in monitors: display, root, monitor, monName = monitorData - if monitor.primary == 1 or len(_monitors) == 1: + if monitor.primary == 1 or len(monitors) == 1: for outputData in outputs: display, screen, root, res, output, outputInfo = outputData if monName == outputInfo.name and outputInfo.crtc: diff --git a/src/pymonctl/_pymonctl_macos.py b/src/pymonctl/_pymonctl_macos.py index b8fcf07..6edcd01 100644 --- a/src/pymonctl/_pymonctl_macos.py +++ b/src/pymonctl/_pymonctl_macos.py @@ -20,8 +20,8 @@ import Quartz import Quartz.CoreGraphics as CG -from pymonctl import BaseMonitor, _pointInBox, _getRelativePosition, \ - DisplayMode, ScreenValue, Box, Rect, Point, Size, Position, Orientation +from ._main import BaseMonitor, _pointInBox, _getRelativePosition, \ + DisplayMode, ScreenValue, Box, Rect, Point, Size, Position, Orientation from ._display_manager_lib import Display @@ -32,6 +32,9 @@ def _getAllMonitors() -> list[MacOSMonitor]: desc = screen.deviceDescription() displayId = desc['NSScreenNumber'] # Quartz.NSScreenNumber seems to be wrong monitors.append(MacOSMonitor(displayId)) + # Alternatives to test: + v, ids, cnt = CG.CGGetOnlineDisplayList(10, None, None) + v, ids, cnt = CG.CGGetActiveDisplayList(10, None, None) return monitors @@ -416,7 +419,7 @@ def setMode(self, mode: Optional[DisplayMode]): try: ret, bestMode = CG.CGDisplayBestModeForParametersAndRefreshRate( self.handle, self.colordepth, mode.width, mode.height, mode.frequency, None) - CG.CGDisplaySwitchToMode(self.handle, bestMode) + CG.CGDisplaySwitchToMode(self.handle, bestMode.get("Mode", 0)) # ret, configRef = Quartz.CGBeginDisplayConfiguration(None) # ret = Quartz.CGConfigureDisplayWithDisplayMode(configRef, self.handle, bestMode, None) # if not ret: @@ -468,14 +471,16 @@ def turnOn(self): # This works, but won't wake up the display despite if the mouse is moving and/or clicking def mouseEvent(eventType, posx, posy): - theEvent = CG.CGEventCreateMouseEvent(None, eventType, CG.CGPointMake(posx, posy), CG.kCGMouseButtonLeft) - CG.CGEventSetType(theEvent, eventType) + ev = CG.CGEventCreateMouseEvent(None, eventType, CG.CGPointMake(posx, posy), CG.kCGMouseButtonLeft) + CG.CGEventSetType(ev, eventType) # or kCGSessionEventTap? - CG.CGEventPost(CG.kCGHIDEventTap, theEvent) - # CG.CFRelease(theEvent) # Produces a Hardware error?!?!?! + CG.CGEventPost(CG.kCGHIDEventTap, ev) + # CG.CFRelease(ev) # Produces a Hardware error?!?!?! def mousemove(posx, posy): mouseEvent(CG.kCGEventMouseMoved, posx, posy) + # Alternative: + CG.CGDisplayMoveCursorToPoint(self.handle, (posx, posy)) def mouseclick(posx, posy): # Not necessary to previously move the mouse to given location diff --git a/src/pymonctl/_pymonctl_win.py b/src/pymonctl/_pymonctl_win.py index 93a2920..343b91a 100644 --- a/src/pymonctl/_pymonctl_win.py +++ b/src/pymonctl/_pymonctl_win.py @@ -17,13 +17,13 @@ import win32evtlog import win32gui -from pymonctl import BaseMonitor, _getRelativePosition, \ - DisplayMode, ScreenValue, Box, Rect, Point, Size, Position, Orientation -from pymonctl.structs import (_QDC_ONLY_ACTIVE_PATHS, _DISPLAYCONFIG_PATH_INFO, _DISPLAYCONFIG_MODE_INFO, _LUID, - _DISPLAYCONFIG_SOURCE_DPI_SCALE_GET, _DISPLAYCONFIG_SOURCE_DPI_SCALE_SET, _DPI_VALUES, - _DISPLAYCONFIG_DEVICE_INFO_GET_DPI_SCALE, _DISPLAYCONFIG_DEVICE_INFO_SET_DPI_SCALE, - _DISPLAYCONFIG_SOURCE_DEVICE_NAME, _DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME - ) +from ._main import BaseMonitor, _getRelativePosition, \ + DisplayMode, ScreenValue, Box, Rect, Point, Size, Position, Orientation +from pymonctl._structs import (_QDC_ONLY_ACTIVE_PATHS, _DISPLAYCONFIG_PATH_INFO, _DISPLAYCONFIG_MODE_INFO, _LUID, + _DISPLAYCONFIG_SOURCE_DPI_SCALE_GET, _DISPLAYCONFIG_SOURCE_DPI_SCALE_SET, _DPI_VALUES, + _DISPLAYCONFIG_DEVICE_INFO_GET_DPI_SCALE, _DISPLAYCONFIG_DEVICE_INFO_SET_DPI_SCALE, + _DISPLAYCONFIG_SOURCE_DEVICE_NAME, _DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME + ) dpiAware = ctypes.windll.user32.GetAwarenessFromDpiAwarenessContext(ctypes.windll.user32.GetThreadDpiAwarenessContext()) @@ -56,8 +56,8 @@ def _getAllMonitorsDict() -> dict[str, ScreenValue]: pass if dev and dev.StateFlags & win32con.DISPLAY_DEVICE_ATTACHED_TO_DESKTOP: name = monName - x, y, r, b = monitorInfo.get("Monitor", (0, 0, -1, -1)) - wx, wy, wr, wb = monitorInfo.get("Work", (0, 0, -1, -1)) + x, y, r, b = monitorInfo["Monitor"] + wx, wy, wr, wb = monitorInfo["Work"] is_primary = monitorInfo.get("Flags", 0) == win32con.MONITORINFOF_PRIMARY pScale = ctypes.c_uint() ctypes.windll.shcore.GetScaleFactorForMonitor(hMon, ctypes.byref(pScale)) @@ -172,28 +172,28 @@ def __init__(self, handle: Optional[int] = None): @property def size(self) -> Optional[Size]: - if self.handle is not None: - monitorInfo = win32api.GetMonitorInfo(self.handle) - if monitorInfo: - x, y, r, b = monitorInfo.get("Monitor", (0, 0, -1, -1)) + monitorInfo = win32api.GetMonitorInfo(self.handle) + if monitorInfo: + x, y, r, b = monitorInfo["Monitor"] + if x is not None: return Size(abs(r - x), abs(b - y)) return None @property def workarea(self) -> Optional[Rect]: - if self.handle is not None: - monitorInfo = win32api.GetMonitorInfo(self.handle) - if monitorInfo: - wx, wy, wr, wb = monitorInfo.get("Work", (0, 0, -1, -1)) + monitorInfo = win32api.GetMonitorInfo(self.handle) + if monitorInfo: + wx, wy, wr, wb = monitorInfo["Work"] + if wx is not None: return Rect(wx, wy, wr, wb) return None @property def position(self) -> Optional[Point]: - if self.handle is not None: - monitorInfo = win32api.GetMonitorInfo(self.handle) - if monitorInfo: - x, y, r, b = monitorInfo.get("Monitor", (0, 0, -1, -1)) + monitorInfo = win32api.GetMonitorInfo(self.handle) + if monitorInfo: + x, y, r, b = monitorInfo["Monitor"] + if x is not None: return Point(x, y) return None @@ -202,34 +202,32 @@ def setPosition(self, relativePos: Union[int, Position], relativeTo: Optional[st @property def box(self) -> Optional[Box]: - if self.handle is not None: - monitorInfo = win32api.GetMonitorInfo(self.handle) - if monitorInfo: - x, y, r, b = monitorInfo.get("Monitor", (0, 0, -1, -1)) + monitorInfo = win32api.GetMonitorInfo(self.handle) + if monitorInfo: + x, y, r, b = monitorInfo["Monitor"] + if x is not None: return Box(x, y, abs(r - x), abs(b - y)) return None @property def rect(self) -> Optional[Rect]: - if self.handle is not None: - monitorInfo = win32api.GetMonitorInfo(self.handle) - if monitorInfo: - x, y, r, b = monitorInfo.get("Monitor", (0, 0, -1, -1)) + monitorInfo = win32api.GetMonitorInfo(self.handle) + if monitorInfo: + x, y, r, b = monitorInfo["Monitor"] + if x is not None: return Rect(x, y, r, b) return None @property def scale(self) -> Optional[Tuple[float, float]]: - if self.handle is not None: - pScale = ctypes.c_uint() - ctypes.windll.shcore.GetScaleFactorForMonitor(self.handle, ctypes.byref(pScale)) - # import wmi - # obj = wmi.WMI().Win32_PnPEntity(ConfigManagerErrorCode=0) - # displays = [x for x in obj if 'DISPLAY' in str(x)] - # for item in displays: - # print(item) - return float(pScale.value), float(pScale.value) - return None + pScale = ctypes.c_uint() + ctypes.windll.shcore.GetScaleFactorForMonitor(self.handle, ctypes.byref(pScale)) + # import wmi + # obj = wmi.WMI().Win32_PnPEntity(ConfigManagerErrorCode=0) + # displays = [x for x in obj if 'DISPLAY' in str(x)] + # for item in displays: + # print(item) + return float(pScale.value), float(pScale.value) def _getPaths(self) -> Tuple[Optional[_LUID], Optional[int]]: @@ -265,7 +263,7 @@ def _getPaths(self) -> Tuple[Optional[_LUID], Optional[int]]: def setScale(self, scale: Optional[Tuple[float, float]]): - if self.handle is not None and scale is not None: + if scale is not None: # https://github.com/lihas/windows-DPI-scaling-sample/blob/master/DPIHelper/DpiHelper.cpp # https://github.com/lihas/windows-DPI-scaling-sample/blob/master/DPIHelper/DpiHelper.cpp # HOW to GET adapterId and sourceId values???? -> QueryDisplayConfig @@ -291,9 +289,9 @@ def setScale(self, scale: Optional[Tuple[float, float]]): scaleValue: int = int(scale[0]) targetScale = -1 - if scale < minScale: + if scaleValue < minScale: targetScale = 0 - elif scale > maxScale: + elif scaleValue > maxScale: targetScale = len(_DPI_VALUES) - 1 else: try: @@ -314,12 +312,10 @@ def setScale(self, scale: Optional[Tuple[float, float]]): @property def dpi(self) -> Optional[Tuple[float, float]]: - if self.handle is not None: - dpiX = ctypes.c_uint() - dpiY = ctypes.c_uint() - ctypes.windll.shcore.GetDpiForMonitor(self.handle, 0, ctypes.byref(dpiX), ctypes.byref(dpiY)) - return dpiX.value, dpiY.value - return None + dpiX = ctypes.c_uint() + dpiY = ctypes.c_uint() + ctypes.windll.shcore.GetDpiForMonitor(self.handle, 0, ctypes.byref(dpiX), ctypes.byref(dpiY)) + return dpiX.value, dpiY.value @property def orientation(self) -> Optional[Union[int, Orientation]]: @@ -355,20 +351,20 @@ def colordepth(self) -> Optional[int]: @property def brightness(self) -> Optional[int]: - if self.handle is not None: - minBright = ctypes.c_uint() - currBright = ctypes.c_uint() - maxBright = ctypes.c_uint() - hDevices = _win32getPhysicalMonitorsHandles(self.handle) - for hDevice in hDevices: - ctypes.windll.dxva2.GetMonitorBrightness(hDevice, ctypes.byref(minBright), ctypes.byref(currBright), - ctypes.byref(maxBright)) - _win32destroyPhysicalMonitors(hDevices) - return currBright.value + minBright = ctypes.c_uint() + currBright = ctypes.c_uint() + maxBright = ctypes.c_uint() + hDevices = _win32getPhysicalMonitorsHandles(self.handle) + for hDevice in hDevices: + ctypes.windll.dxva2.GetMonitorBrightness(hDevice, ctypes.byref(minBright), ctypes.byref(currBright), + ctypes.byref(maxBright)) + _win32destroyPhysicalMonitors(hDevices) + return currBright.value return None + def setBrightness(self, brightness: Optional[int]): - if brightness is not None and self.handle is not None: + if brightness is not None: minBright = ctypes.c_uint() currBright = ctypes.c_uint() maxBright = ctypes.c_uint() @@ -388,20 +384,19 @@ def setBrightness(self, brightness: Optional[int]): @property def contrast(self) -> Optional[int]: - if self.handle is not None: - minCont = ctypes.c_uint() - currCont = ctypes.c_uint() - maxCont = ctypes.c_uint() - hDevices = _win32getPhysicalMonitorsHandles(self.handle) - for hDevice in hDevices: - ctypes.windll.dxva2.GetMonitorContrast(hDevice, ctypes.byref(minCont), ctypes.byref(currCont), - ctypes.byref(maxCont)) - _win32destroyPhysicalMonitors(hDevices) - return currCont.value + minCont = ctypes.c_uint() + currCont = ctypes.c_uint() + maxCont = ctypes.c_uint() + hDevices = _win32getPhysicalMonitorsHandles(self.handle) + for hDevice in hDevices: + ctypes.windll.dxva2.GetMonitorContrast(hDevice, ctypes.byref(minCont), ctypes.byref(currCont), + ctypes.byref(maxCont)) + _win32destroyPhysicalMonitors(hDevices) + return currCont.value return None def setContrast(self, contrast: Optional[int]): - if contrast is not None and self.handle is not None: + if contrast is not None: minCont = ctypes.c_uint() currCont = ctypes.c_uint() maxCont = ctypes.c_uint() @@ -466,7 +461,7 @@ def setPrimary(self): def turnOn(self): # https://stackoverflow.com/questions/16402672/control-screen-with-python - if self.handle is not None and self._hasVCPSupport and self._hasVCPPowerSupport: + if self._hasVCPSupport and self._hasVCPPowerSupport: if not self.isOn: hDevices = _win32getPhysicalMonitorsHandles(self.handle) for hDevice in hDevices: @@ -484,7 +479,7 @@ def turnOn(self): def turnOff(self): # https://stackoverflow.com/questions/16402672/control-screen-with-python - if self.handle is not None and self._hasVCPSupport and self._hasVCPPowerSupport: + if self._hasVCPSupport and self._hasVCPPowerSupport: if self.isOn: hDevices = _win32getPhysicalMonitorsHandles(self.handle) for hDevice in hDevices: @@ -497,7 +492,7 @@ def turnOff(self): win32con.SMTO_ABORTIFHUNG, 100) def suspend(self): - if self.handle is not None and self._hasVCPSupport and self._hasVCPPowerSupport: + if self._hasVCPSupport and self._hasVCPPowerSupport: hDevices = _win32getPhysicalMonitorsHandles(self.handle) for hDevice in hDevices: # code and value according to: VESA Monitor Control Command Set (MCCS) standard, version 1.0 and 2.0. @@ -511,26 +506,25 @@ def suspend(self): @property def isOn(self) -> Optional[bool]: ret = None - if self.handle is not None: - if self._hasVCPSupport and self._hasVCPPowerSupport: - hDevices = _win32getPhysicalMonitorsHandles(self.handle) - for hDevice in hDevices: - # code and value according to: VESA Monitor Control Command Set (MCCS) standard, version 1.0 and 2.0. - pvct = ctypes.c_uint() - currValue = ctypes.c_uint() - maxValue = ctypes.c_uint() - ctypes.windll.dxva2.GetVCPFeatureAndVCPFeatureReply(hDevice, 0xD6, ctypes.byref(pvct), - ctypes.byref(currValue), ctypes.byref(maxValue)) - ret = currValue.value == 1 - _win32destroyPhysicalMonitors(hDevices) - else: - # Not working by now (tried with hDevice as well) - # https://stackoverflow.com/questions/203355/is-there-any-way-to-detect-the-monitor-state-in-windows-on-or-off - # https://learn.microsoft.com/en-us/windows/win32/power/power-management-functions - is_working = ctypes.c_uint() - res = ctypes.windll.kernel32.GetDevicePowerState(self.handle, ctypes.byref(is_working)) - if res: - ret = bool(is_working.value == 1) + if self._hasVCPSupport and self._hasVCPPowerSupport: + hDevices = _win32getPhysicalMonitorsHandles(self.handle) + for hDevice in hDevices: + # code and value according to: VESA Monitor Control Command Set (MCCS) standard, version 1.0 and 2.0. + pvct = ctypes.c_uint() + currValue = ctypes.c_uint() + maxValue = ctypes.c_uint() + ctypes.windll.dxva2.GetVCPFeatureAndVCPFeatureReply(hDevice, 0xD6, ctypes.byref(pvct), + ctypes.byref(currValue), ctypes.byref(maxValue)) + ret = currValue.value == 1 + _win32destroyPhysicalMonitors(hDevices) + else: + # Not working by now (tried with hDevice as well) + # https://stackoverflow.com/questions/203355/is-there-any-way-to-detect-the-monitor-state-in-windows-on-or-off + # https://learn.microsoft.com/en-us/windows/win32/power/power-management-functions + is_working = ctypes.c_uint() + res = ctypes.windll.kernel32.GetDevicePowerState(self.handle, ctypes.byref(is_working)) + if res: + ret = bool(is_working.value == 1) return ret def attach(self): @@ -626,14 +620,14 @@ def _setPosition(relativePos: Union[int, Position], relativeTo: Optional[str], n monitors = _win32getAllMonitorsDict() if name in monitors.keys() and relativeTo in monitors.keys(): targetMonInfo = monitors[name]["monitor"] - x, y, r, b = targetMonInfo.get("Monitor", (0, 0, -1, -1)) + x, y, r, b = targetMonInfo["Monitor"] w = abs(r - x) h = abs(b - y) targetMon = {"relativePos": relativePos, "relativeTo": relativeTo, "position": Point(x, y), "size": Size(w, h)} relMonInfo = monitors[relativeTo]["monitor"] - x, y, r, b = relMonInfo.get("Monitor", (0, 0, -1, -1)) + x, y, r, b = relMonInfo["Monitor"] w = abs(r - x) h = abs(b - y) relMon = {"position": Point(x, y), "size": Size(w, h)} @@ -809,6 +803,3 @@ def _eventLogLoop(kill: threading.Event, interval: float): kill.wait(interval) win32evtlog.CloseEventLog(handle) - -# import win32ui -# print(win32ui.GetDeviceCaps(win32gui.GetDC(None), win32con.HORZSIZE), win32ui.GetDeviceCaps(win32gui.GetDC(None), win32con.VERTSIZE)) \ No newline at end of file diff --git a/src/pymonctl/structs.py b/src/pymonctl/_structs.py similarity index 84% rename from src/pymonctl/structs.py rename to src/pymonctl/_structs.py index 5c0546a..ba8fafa 100644 --- a/src/pymonctl/structs.py +++ b/src/pymonctl/_structs.py @@ -2,7 +2,6 @@ # -*- coding: utf-8 -*- from __future__ import annotations -import ctypes import ctypes.wintypes from enum import IntEnum from typing import NamedTuple, Tuple @@ -10,6 +9,7 @@ class Box(NamedTuple): + """Container class to handle Box struct (left, top, width, height)""" left: int top: int width: int @@ -17,6 +17,7 @@ class Box(NamedTuple): class Rect(NamedTuple): + """Container class to handle Rect struct (left, top, right, bottom)""" left: int top: int right: int @@ -24,16 +25,33 @@ class Rect(NamedTuple): class Point(NamedTuple): + """Container class to handle Point struct (x, y)""" x: int y: int class Size(NamedTuple): + """Container class to handle Size struct (right, bottom)""" width: int height: int class ScreenValue(TypedDict): + """ + Container class to handle ScreenValue struct: + + - system_name (str): name of the monitor as known by the system + - id (int): handle/identifier of the monitor + - is_primary (bool): ''True'' if it is the primary monitor + - position (Point): position of the monitor + - size (Size): size of the monitor, in pixels + - workarea (Rect): coordinates of the usable area of the monitor usable by apps/windows (no docks, taskbars, ...) + - scale (Tuple[int, int]): text scale currently applied to monitor + - dpi (Tuple[int, int]): dpi values of current resolution + - orientation (int): rotation value of the monitor as per Orientation values (NORMAL = 0, RIGHT = 1, INVERTED = 2, LEFT = 3) + - frequency (float): refresh rate of the monitor + - colordepth (int): color depth of the monitor + """ system_name: str id: int is_primary: bool @@ -48,6 +66,13 @@ class ScreenValue(TypedDict): class DisplayMode(NamedTuple): + """ + Container class to handle DisplayMode struct: + + - width (int): width, in pixels + - height (int): height, in pixels + - frequency (float): refresh rate + """ width: int height: int frequency: float diff --git a/tests/test_pymonctl.py b/tests/test_pymonctl.py index 1ec7d0e..fc0dbf2 100644 --- a/tests/test_pymonctl.py +++ b/tests/test_pymonctl.py @@ -6,7 +6,7 @@ from typing import Union import pymonctl as pmc -from pymonctl.structs import * +from pymonctl._structs import * def countChanged(names, screensInfo):