diff --git a/AUTHORS.txt b/AUTHORS.txt index d9c02aa..264c2c7 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -1,3 +1,4 @@ PyMonCtl authors, contributors and maintainers: Kalmat https://github.com/Kalmat +University of Utah - Marriott Library - Apple Infrastructure https://github.com/univ-of-utah-marriott-library-apple \ No newline at end of file diff --git a/CHANGES.txt b/CHANGES.txt index 1c34714..5703034 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,2 +1,7 @@ +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 + LINUX: Added attach()/detach(), fixed setPosition() and arrangeMonitors() + WIN32: Fixed and improved many issues (scale still pending) 0.0.9, 2023/07/19 -- New approach based on Monitor() class to access all properties and functionalities (macOS pending) 0.0.8, 2023/05/12 -- Pre-release tested OK in Linux/X11 (not Wayland) and win32 (macOS pending) diff --git a/DISPLAY_MANAGER_LICENSE.txt b/DISPLAY_MANAGER_LICENSE.txt new file mode 100644 index 0000000..25dade2 --- /dev/null +++ b/DISPLAY_MANAGER_LICENSE.txt @@ -0,0 +1,14 @@ +######################################################################## +# Copyright (c) 2018 University of Utah Student Computing Labs. # +# All Rights Reserved. # +# # +# Permission to use, copy, modify, and distribute this software and # +# its documentation for any purpose and without fee is hereby granted, # +# provided that the above copyright notice appears in all copies and # +# that both that copyright notice and this permission notice appear # +# in supporting documentation, and that the name of The University # +# of Utah not be used in advertising or publicity pertaining to # +# distribution of the software without specific, written prior # +# permission. This software is supplied as is without expressed or # +# implied warranties of any kind. # +######################################################################## diff --git a/LICENSE.txt b/LICENSE.txt index 92eaf79..816d7e7 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -24,4 +24,6 @@ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + diff --git a/README.md b/README.md index 45ee683..368cec3 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ 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 ## General Functions @@ -31,48 +32,50 @@ getPrimary() or findMonitor(x, y). To instantiate it, you need to pass the monitor handle (OS-dependent). It can raise ValueError exception in case the provided handle is not valid. -| | Windows | Linux | macOS | -|:--------------:|:-------:|:-----:|:-----:| -| size | X | X | X | -| workarea | X | X | X | -| position | X | X | X | -| setPosition | X | X | X | -| box | X | X | X | -| rect | X | X | X | -| scale | X | X | X | -| setScale | X | X | X | -| dpi | X | X | X | -| orientation | X | X | X | -| setOrientation | X | X | | -| frequency | X | X | X | -| colordepth | X | X | X | -| brightness | X (1) | X | | -| setBrightness | X (1) | X | | -| contrast | X (1) | X | | -| setContrast | X (1) | X | | -| mode | X | X | X | -| setMode | X | X | X | -| defaultMode | X | X | X | -| setDefaultMode | X | X | X | -| allModes | X | X | X | -| isPrimary | X | X | X | -| setPrimary | X | X | X | -| turnOn | X | X | | -| turnOff | X (2) | X | | -| suspend | X (2) | X (3) | X (3) | -| isOn | X | X | | -| attach | X | X | | -| detach | X | X | | -| isAttached | X | X | X | - - -(1) If monitor has no VCP MCCS support, these methods won't likely work. - -(2) If monitor has no VCP MCCS support, it can not be addressed separately, +| | Windows | Linux | macOS | +|:--------------:|:-------:|:------:|:-----:| +| size | X | X | X | +| workarea | X | X | X | +| position | X | X | X | +| setPosition | X | X | X | +| box | X | X | X | +| rect | X | X | X | +| scale | X | X | X | +| setScale | X | X | X | +| dpi | X | X | X | +| orientation | X | X | X | +| setOrientation | X | X | X (1) | +| frequency | X | X | X | +| colordepth | X | X | X | +| brightness | X (2) | X | X (1) | +| setBrightness | X (2) | X | X (1) | +| contrast | X (2) | X | | +| setContrast | X (2) | X | | +| mode | X | X | X | +| setMode | X | X | X | +| defaultMode | X | X | X | +| setDefaultMode | X | X | X | +| allModes | X | X | X | +| isPrimary | X | X | X | +| setPrimary | X | X | X | +| turnOn | X | X | | +| turnOff | X (3) | X | | +| suspend | X (3) | X (4) | X (4) | +| isOn | X | X | | +| attach | X | X | | +| detach | X | X | | +| isAttached | X | X | X | + + +(1) Thru display_manager_lib from University of Utah - Marriott Library - Apple Infrastructure (thank you, guys!) + +(2) If monitor has no VCP MCCS support, these methods won't likely work. + +(3) If monitor has no VCP MCCS support, it can not be addressed separately, so ALL monitors will be turned off / suspended. To address a specific monitor, try using detach() / attach() methods. -(3) It will suspend ALL monitors. +(4) It will suspend ALL monitors. #### WARNING: Most of these properties may return ''None'' in case the value can not be obtained diff --git a/TODO.txt b/TODO.txt index 699e2d1..4924d69 100644 --- a/TODO.txt +++ b/TODO.txt @@ -8,7 +8,6 @@ WINDOWS - Find a solution for changing scale MACOS -- Contact display-manager-lib team to check alternatives or find another solution (for setOrientation, brightness) - Test everything in an actual macOS installation with a multi-monitor setup - Check returned coordinates (multi-monitor, flipped, ...) - Find a way to turn monitor OFF/ON/SUSPEND/WAKEUP/DETACH/ATTACH diff --git a/dist/PyMonCtl-0.0.10-py3-none-any.whl b/dist/PyMonCtl-0.0.11-py3-none-any.whl similarity index 82% rename from dist/PyMonCtl-0.0.10-py3-none-any.whl rename to dist/PyMonCtl-0.0.11-py3-none-any.whl index b365968..a36c427 100644 Binary files a/dist/PyMonCtl-0.0.10-py3-none-any.whl and b/dist/PyMonCtl-0.0.11-py3-none-any.whl differ diff --git a/src/pymonctl/__init__.py b/src/pymonctl/__init__.py index 1ccb854..45f7551 100644 --- a/src/pymonctl/__init__.py +++ b/src/pymonctl/__init__.py @@ -19,7 +19,7 @@ "getMousePos", "version", "Monitor" ] -__version__ = "0.0.10" +__version__ = "0.0.11" def version(numberOnly: bool = True) -> str: diff --git a/src/pymonctl/_display_manager_lib.py b/src/pymonctl/_display_manager_lib.py new file mode 100644 index 0000000..f6c823d --- /dev/null +++ b/src/pymonctl/_display_manager_lib.py @@ -0,0 +1,747 @@ +#!/usr/bin/python3 + +######################################################################## +# Copyright (c) 2018 University of Utah Student Computing Labs. # +# All Rights Reserved. # +# # +# Permission to use, copy, modify, and distribute this software and # +# its documentation for any purpose and without fee is hereby granted, # +# provided that the above copyright notice appears in all copies and # +# that both that copyright notice and this permission notice appear # +# in supporting documentation, and that the name of The University # +# of Utah not be used in advertising or publicity pertaining to # +# distribution of the software without specific, written prior # +# permission. This software is supplied as is without expressed or # +# implied warranties of any kind. # +######################################################################## + +# Display Manager, version 1.0.2 +# Python Library + +# Programmatically manages Mac displays. +# Can set screen resolution, refresh rate, rotation, brightness, underscan, and screen mirroring. + +import sys # make decisions based on system configuration +import warnings # control warning settings for +from abc import abstractmethod, ABCMeta # allows use of abstract classes +from typing import Optional, Dict, Callable + +import objc # type: ignore[import] # access Objective-C functions and variables +import CoreFoundation # type: ignore[import] #work with Objective-C data types +import Quartz # work with system graphics + + +# Configured for global usage; otherwise, must be re-instantiated each time it is called +iokit: Optional[Dict[str, Callable]] = None # type: ignore[type-arg] + + +class DisplayError(Exception): + """ + Raised if a display cannot perform the requested operation (or access the requested property) + (e.g. does not have a matching display mode, display cannot modify this setting, etc.) + """ + pass + + +class AbstractDisplay(object): + """ + Abstract representation which display_manager_lib.Display will inherit from. + + Included for unit testing purposes + """ + + __metaclass__ = ABCMeta + + @abstractmethod + def __init__(self, displayID): + self.displayID = displayID + + # "Magic" methods + + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.displayID == other.displayID + else: + return NotImplemented + + def __ne__(self, other): + if isinstance(other, self.__class__): + return self.displayID != other.displayID + else: + return NotImplemented + + def __lt__(self, other): + return self.displayID < other.displayID + + def __gt__(self, other): + return self.displayID > other.displayID + + def __hash__(self): + # Actually just returns self.displayID, as self.displayID is int; + # hash() is called for consistency and compatibility + return hash(self.displayID) + + # General properties + + @property + @abstractmethod + def isMain(self): + pass + + @property + @abstractmethod + def tag(self): + pass + + # Mode properties and methods + + @property + @abstractmethod + def currentMode(self): + pass + + @property + @abstractmethod + def allModes(self): + pass + + @abstractmethod + def highestMode(self, hidpi): + pass + + @abstractmethod + def closestMode(self, width, height, refresh, hidpi): + pass + + @abstractmethod + def setMode(self, mode): + pass + + # Rotation properties and methods + + @property + @abstractmethod + def rotation(self): + pass + + @abstractmethod + def setRotate(self, angle): + pass + + # Brightness + + @property + @abstractmethod + def brightness(self): + pass + + @abstractmethod + def setBrightness(self, brightness): + pass + + # Underscan + + @property + @abstractmethod + def underscan(self): + pass + + @abstractmethod + def setUnderscan(self, underscan): + pass + + # Mirroring + + @property + @abstractmethod + def mirrorSource(self): + pass + + @abstractmethod + def setMirrorSource(self, mirrorDisplay): + pass + + +class Display(AbstractDisplay): + """ + Virtual representation of a physical display. + + Contains properties regarding display information for a given physical display, along with a few + useful helper functions to configure the display. + """ + + def __init__(self, displayID): + """ + :param displayID: The DisplayID of the display to manipulate + """ + # Make sure displayID is actually a display + (error, allDisplayIDs, count) = Quartz.CGGetOnlineDisplayList(32, None, None) # max 32 displays + if displayID not in allDisplayIDs or error: + raise DisplayError("Display with ID \"{}\" not found".format(displayID)) + + # Sets self.displayID to displayID + super(Display, self).__init__(displayID) + + # iokit is required for several Display methods + getIOKit() + + # General properties + + @property + def tag(self): + """ + :return: The display tag for this Display + """ + if self.isMain: + return "main" + # is external display + else: + # Get all the external displays (in order) + externals = sorted(getAllDisplays()) + for display in externals: + if display.isMain: + externals.remove(display) + break + + for i in range(len(externals)): + if self == externals[i]: + return "ext" + str(i) + + @property + def isMain(self): + """ + :return: Boolean for whether this Display is the main display + """ + return Quartz.CGDisplayIsMain(self.displayID) + + @property + def isHidpi(self): + """ + :return: Whether this display can be set to HiDPI resolutions + """ + # Check if self.allModes has any HiDPI modes + for mode in self.allModes: + if mode.hidpi: + return True + # None of self.allModes were HiDPI + return False + + # Helper methods, properties + + @property + def __servicePort(self): + """ + :return: The integer representing this display's service port. + """ + return Quartz.CGDisplayIOServicePort(self.displayID) + + @staticmethod + def __rightHidpi(mode, hidpi): + """ + Evaluates whether the mode fits the user's HiDPI specification. + + :param mode: The mode to be evaluated. + :param hidpi: HiDPI code. 0 returns everything, 1 returns only non-HiDPI, and 2 returns only HiDPI. + :return: Whether the mode fits the HiDPI description specified by the user. + """ + if ( + (hidpi == 0) # fits HiDPI or non-HiDPI (default) + or (hidpi == 1 and not mode.hidpi) # fits only non-HiDPI + or (hidpi == 2 and mode.hidpi) # fits only HiDPI + ): + return True + else: + return False + + # Mode properties and methods + + @property + def currentMode(self): + """ + :return: The current Quartz "DisplayMode" interface for this display. + """ + return DisplayMode(Quartz.CGDisplayCopyDisplayMode(self.displayID)) + + @property + def defaultMode(self): + for mode in self.allModes: + if mode.isDefault: + return mode + # No default mode was found + return None + + @property + def allModes(self): + """ + :return: All possible Quartz "DisplayMode" interfaces for this display. + """ + # TO-DO: This needs to be revisited + modes = [] + # options forces Quartz to show HiDPI modes + options = {Quartz.kCGDisplayShowDuplicateLowResolutionModes: True} + for modeRef in Quartz.CGDisplayCopyAllDisplayModes(self.displayID, options): + modes.append(DisplayMode(modeRef)) + + # Eliminate all duplicate modes, including any modes that duplicate "default" + uniqueModes = set(modes) + defaultMode = None + # Find default mode + for mode in uniqueModes: + if mode.isDefault: + defaultMode = mode + if defaultMode: + # If there are any duplicates of defaultMode, remove them (and not defaultMode) + for mode in modes: + if all([ + defaultMode.width == mode.width, + defaultMode.height == mode.height, + defaultMode.refresh == mode.refresh, + defaultMode.hidpi == mode.hidpi, + not mode.isDefault, + ]): + try: + uniqueModes.remove(mode) + except KeyError: + pass + + return list(uniqueModes) + + def highestMode(self, hidpi=0): + """ + :param hidpi: HiDPI code. 0 returns everything, 1 returns only non-HiDPI, and 2 returns only HiDPI. + :return: The Quartz "DisplayMode" interface with the highest display resolution for this display. + """ + highest = None + for mode in self.allModes: + if highest: + if mode > highest and self.__rightHidpi(mode, hidpi): + highest = mode + else: # highest hasn't been set yet, so anything is the highest + highest = mode + + if highest: + return highest + else: + if hidpi == 1: + raise DisplayError( + "Display \"{}\" cannot be set to any non-HiDPI resolutions".format(self.tag)) + elif hidpi == 2: + raise DisplayError( + "Display \"{}\" cannot be set to any HiDPI resolutions".format(self.tag)) + else: + raise DisplayError( + "Display \"{}\"\'s resolution cannot be set".format(self.tag)) + + def closestMode(self, width, height, refresh=0, hidpi=0): + """ + :param width: Desired width + :param height: Desired height + :param refresh: Desired refresh rate + :param hidpi: HiDPI code. 0 returns everything, 1 returns only non-HiDPI, and 2 returns only HiDPI + :return: The closest Quartz "DisplayMode" interface possible for this display. + """ + # Which criteria does it match (in addition to width and height)? + both = [] # matches HiDPI and refresh + onlyHidpi = [] # matches HiDPI + onlyRefresh = [] # matches refresh + + for mode in self.allModes: + if mode.width == width and mode.height == height: + if self.__rightHidpi(mode, hidpi) and mode.refresh == refresh: + both.append(mode) + elif self.__rightHidpi(mode, hidpi): + onlyHidpi.append(mode) + elif mode.refresh == refresh: + onlyRefresh.append(mode) + + # Return the nearest match, with HiDPI matches preferred over refresh matches + for modes in [both, onlyHidpi, onlyRefresh]: + if modes: + return modes[0] + + raise DisplayError( + "Display \"{}\" cannot be set to {}x{}".format(self.tag, width, height) + ) + + def setMode(self, mode): + """ + :param mode: The Quartz "DisplayMode" interface to set this display to. + """ + (error, configRef) = Quartz.CGBeginDisplayConfiguration(None) + if error: + raise DisplayError( + "Display \"{}\"\'s resolution cannot be set to {}x{} at {} Hz".format( + self.tag, mode.width, mode.height, mode.refresh)) + + error = Quartz.CGConfigureDisplayWithDisplayMode(configRef, self.displayID, mode.raw, None) + if error: + Quartz.CGCancelDisplayConfiguration(configRef) + raise DisplayError( + "Display \"{}\"\'s resolution cannot be set to {}x{} at {} Hz".format( + self.tag, mode.width, mode.height, mode.refresh)) + + Quartz.CGCompleteDisplayConfiguration(configRef, Quartz.kCGConfigurePermanently) + + # Rotation properties and methods + + @property + def rotation(self): + """ + :return: Rotation of this display, in degrees. + """ + return int(Quartz.CGDisplayRotation(self.displayID)) + + def setRotate(self, angle): + """ + :param angle: The angle of rotation. + """ + # see: https://opensource.apple.com/source/IOGraphics/IOGraphics-406/IOGraphicsFamily/IOKit/graphics/ + # IOGraphicsTypes.h for angle codes (kIOScaleRotate{0, 90, 180, 270}). + # Likewise, see .../IOKit/graphics/IOGraphicsTypesPrivate.h for rotateCode (kIOFBSetTransform) + swapAxes = 0x10 + invertX = 0x20 + invertY = 0x40 + angleCodes = { + 0: 0, + 90: (swapAxes | invertX) << 16, + 180: (invertX | invertY) << 16, + 270: (swapAxes | invertY) << 16, + } + rotateCode = 0x400 + + # If user enters inappropriate angle, we should quit + if angle % 90 != 0: + raise ValueError("Can only rotate by multiples of 90 degrees.") + options = rotateCode | angleCodes[angle % 360] + + # Actually rotate the screen + global iokit + if iokit: + error = iokit["IOServiceRequestProbe"](self.__servicePort, options) + if error: + raise DisplayError("Cannot manage rotation on display \"{}\"".format(self.tag)) + + # Brightness properties and methods + + @property + def brightness(self): + """ + :return: Brightness of this display, from 0 to 1. + """ + global iokit + if iokit: + service = self.__servicePort + (error, brightness) = iokit["IODisplayGetFloatParameter"](service, 0, iokit["kDisplayBrightness"], None) + if error: + return None + else: + return brightness + + def setBrightness(self, brightness): + """ + :param brightness: The desired brightness, from 0 to 1. + """ + global iokit + if iokit: + error = iokit["IODisplaySetFloatParameter"](self.__servicePort, 0, iokit["kDisplayBrightness"], brightness) + if error: + if self.isMain: + raise DisplayError("Cannot manage brightness on display \"{}\"".format(self.tag)) + else: + raise DisplayError( + "Display \"{}\"\'s brightness cannot be set.\n" + "External displays may not be compatible with Display Manager. " + "Try setting manually on device hardware.".format(self.tag)) + + # Underscan properties and methods + + @property + def underscan(self): + """ + :return: Display's active underscan setting, from 1 (0%) to 0 (100%). + (Yes, it doesn't really make sense to have 1 -> 0 and 0 -> 100, but it's how IOKit reports it.) + """ + global iokit + if iokit: + (error, underscan) = iokit["IODisplayGetFloatParameter"]( + self.__servicePort, 0, iokit["kDisplayUnderscan"], None) + if error: + return None + else: + # IOKit handles underscan values as the opposite of what makes sense, so I switch it here. + # e.g. 0 -> maximum (100%), 1 -> 0% (default) + return float(abs(underscan - 1)) + + def setUnderscan(self, underscan): + """ + :param underscan: Underscan value, from 0 (no underscan) to 1 (maximum underscan). + """ + # IOKit handles underscan values as the opposite of what makes sense, so I switch it here. + # e.g. 0 -> maximum (100%), 1 -> 0% (default) + underscan = float(abs(underscan - 1)) + + global iokit + if iokit: + error = iokit["IODisplaySetFloatParameter"](self.__servicePort, 0, iokit["kDisplayUnderscan"], underscan) + if error: + raise DisplayError("Cannot manage underscan on display \"{}\"".format(self.tag)) + + # Mirroring properties and methods + + @property + def mirrorSource(self): + """ + Checks whether self is mirroring another display + :return: The Display that self is mirroring; if self is not mirroring + any display, returns None + """ + # The display which self is mirroring + masterDisplayID = Quartz.CGDisplayMirrorsDisplay(self.displayID) + if masterDisplayID == Quartz.kCGNullDirectDisplay: + # self is not mirroring any display + return None + else: + return Display(masterDisplayID) + + def setMirrorSource(self, mirrorDisplay): + """ + :param mirrorDisplay: The Display which this Display will mirror. + Input a NoneType to stop mirroring. + """ + (error, configRef) = Quartz.CGBeginDisplayConfiguration(None) + if error: + raise DisplayError( + "Display \"{}\" cannot be set to mirror display \"{}\"".format(self.tag, mirrorDisplay.tag)) + + # Will be passed a None mirrorDisplay to disable mirroring. Cannot mirror self. + if mirrorDisplay is None or mirrorDisplay.displayID == self.displayID: + Quartz.CGConfigureDisplayMirrorOfDisplay(configRef, self.displayID, Quartz.kCGNullDirectDisplay) + else: + Quartz.CGConfigureDisplayMirrorOfDisplay(configRef, self.displayID, mirrorDisplay.displayID) + + Quartz.CGCompleteDisplayConfiguration(configRef, Quartz.kCGConfigurePermanently) + + +class AbstractDisplayMode(object): + """ + Abstract representation which display_manager_lib.DisplayMode will inherit from. + + Included for unit testing purposes + """ + + __metaclass__ = ABCMeta + + @abstractmethod + def __init__(self, mode): + self.raw = mode + + # "Magic" methods + + def __eq__(self, other): + if isinstance(other, self.__class__): + return all([ + self.width == other.width, + self.height == other.height, + self.refresh == other.refresh, + self.hidpi == other.hidpi, + self.isDefault == other.isDefault, + ]) + else: + return NotImplemented + + def __ne__(self, other): + if isinstance(other, self.__class__): + return not self.__eq__(other) + else: + return NotImplemented + + def __lt__(self, other): + return self.width * self.height < other.width * other.height + + def __gt__(self, other): + return self.width * self.height > other.width * other.height + + def __hash__(self): + return hash((self.width, self.height, self.refresh, self.hidpi, self.isDefault)) + + # General properties + + @property + @abstractmethod + def width(self): + pass + + @property + @abstractmethod + def height(self): + pass + + @property + @abstractmethod + def refresh(self): + pass + + @property + @abstractmethod + def hidpi(self): + pass + + @property + @abstractmethod + def isDefault(self): + pass + + +class DisplayMode(AbstractDisplayMode): + """ + Represents a DisplayMode as implemented in Quartz.CoreGraphics + """ + + def __init__(self, mode): + if not isinstance(mode, Quartz.CGDisplayModeRef): + raise DisplayError("\"{}\" is not a valid Quartz.CGDisplayModeRef".format(mode)) + # sets self.raw to mode + super(DisplayMode, self).__init__(mode) + + self.__width = int(Quartz.CGDisplayModeGetWidth(mode)) + self.__height = int(Quartz.CGDisplayModeGetHeight(mode)) + self.__refresh = int(Quartz.CGDisplayModeGetRefreshRate(mode)) + + maxWidth = Quartz.CGDisplayModeGetPixelWidth(mode) # the maximum display width for this display + maxHeight = Quartz.CGDisplayModeGetPixelHeight(mode) # the maximum display width for this display + self.__hidpi = (maxWidth != self.width and maxHeight != self.height) # if they're the same, mode is not HiDPI + + # General properties + + @property + def littleString(self): + return "resolution: {width}x{height}, refresh rate: {refresh}, HiDPI: {hidpi}".format(**{ + "width": self.width, + "height": self.height, + "refresh": self.refresh, + "hidpi": self.hidpi, + }) + + @property + def bigString(self): + return "\n".join([ + "resolution: {}x{}".format(self.width, self.height), + "refresh rate: {}".format(self.refresh), + "HiDPI: {}".format(self.hidpi), + ]) + + @property + def width(self): + return self.__width + + @property + def height(self): + return self.__height + + @property + def refresh(self): + return self.__refresh + + @property + def hidpi(self): + return self.__hidpi + + @property + def isDefault(self): + """ + :return: Whether this DisplayMode is the display's default mode + """ + # CGDisplayModeGetIOFlags returns a hexadecimal number representing the DisplayMode's flags + # the "default" flag is 0x4, which means that said number's binary representation must have + # a '1' in the third-to-last position for it to be the default + return bin(Quartz.CGDisplayModeGetIOFlags(self.raw))[-3] == '1' + + +def getMainDisplay(): + """ + :return: The main Display. + """ + return Display(Quartz.CGMainDisplayID()) + + +def getAllDisplays(): + """ + :return: A list containing all currently-online displays. + """ + (error, displayIDs, count) = Quartz.CGGetOnlineDisplayList(32, None, None) # max 32 displays + if error: + raise DisplayError("Could not retrieve displays list") + + displays = [] + for displayID in displayIDs: + displays.append(Display(displayID)) + return sorted(displays) + + +def getIOKit(): + """ + This handles the importing of specific functions and variables from the + IOKit framework. IOKit is not natively bridged in PyObjC, so the methods + must be found and encoded manually to gain their functionality in Python. + + :return: A dictionary containing several IOKit functions and variables. + """ + global iokit + + # IOKit may have already been instantiated, in which case, nothing needs to be done + if not iokit: + # PyObjC sometimes raises compatibility warnings in macOS 10.14 relating to parts of IOKit that + # Display Manager doesn't use. Thus, such warnings will be temporarily ignored + warnings.simplefilter("ignore") + + # The dictionary which will contain all of the necessary functions and variables from IOKit + iokit = {} + + # Retrieve the IOKit framework + iokitBundle = objc.initFrameworkWrapper( + "IOKit", + frameworkIdentifier="com.apple.iokit", + frameworkPath=objc.pathForFramework("/System/Library/Frameworks/IOKit.framework"), + globals=globals() + ) + + # The IOKit functions to be retrieved + functions = [ + ("IOServiceGetMatchingServices", b"iI@o^I"), + ("IODisplayCreateInfoDictionary", b"@II"), + ("IODisplayGetFloatParameter", b"iII@o^f"), + ("IODisplaySetFloatParameter", b"iII@f"), + ("IOServiceRequestProbe", b"iII"), + ("IOIteratorNext", b"II"), + ] + + # The IOKit variables to be retrieved + # The IOKit variables to be retrieved + variables = [ + ("kIODisplayNoProductName", b"I"), + ("kIOMasterPortDefault", b"I"), + ("kIODisplayOverscanKey", b"*"), + ("kDisplayVendorID", b"*"), + ("kDisplayProductID", b"*"), + ("kDisplaySerialNumber", b"*"), + ] + + # Load functions from IOKit.framework into our iokit + objc.loadBundleFunctions(iokitBundle, iokit, functions) + # Bridge won't put straight into iokit, so globals() + objc.loadBundleVariables(iokitBundle, globals(), variables) + # Move only the desired variables into iokit + for var in variables: + key = "{}".format(var[0]) + if key in globals(): + iokit[key] = globals()[key] + + # A few IOKit variables that have been deprecated, but whose values + # still work as intended in IOKit functions + iokit["kDisplayBrightness"] = CoreFoundation.CFSTR("brightness") + iokit["kDisplayUnderscan"] = CoreFoundation.CFSTR("pscn") + + # Stop ignoring warnings now that we've finished with PyObjC + warnings.simplefilter("default") + + return iokit diff --git a/src/pymonctl/_pymonctl_macos.py b/src/pymonctl/_pymonctl_macos.py index 4ce958e..a2642ce 100644 --- a/src/pymonctl/_pymonctl_macos.py +++ b/src/pymonctl/_pymonctl_macos.py @@ -20,7 +20,7 @@ from pymonctl import BaseMonitor, _pointInBox, _getRelativePosition, \ DisplayMode, ScreenValue, Box, Rect, Point, Size, Position, Orientation -# from ._display_manager_lib import Display +from ._display_manager_lib import Display def _getAllMonitors() -> list[MacOSMonitor]: @@ -196,7 +196,7 @@ def __init__(self, handle: Optional[int] = None): except: # In older macOS, screen doesn't have localizedName() method self.name = "Display" + "_" + str(self.handle) - # self._dm = Display(self.handle) + self._dm = Display(self.handle) else: raise ValueError @@ -308,9 +308,8 @@ def orientation(self) -> Optional[Union[int, Orientation]]: return None def setOrientation(self, orientation: Optional[Union[int, Orientation]]): - # if orientation in (NORMAL, INVERTED, LEFT, RIGHT): - # self._dm.setRotate(orientation * 90) - pass + if orientation in (Orientation.NORMAL, Orientation.INVERTED, Orientation.LEFT, Orientation.RIGHT): + self._dm.setRotate(orientation * 90) @property def frequency(self) -> Optional[float]: @@ -324,8 +323,7 @@ def colordepth(self) -> Optional[int]: @property def brightness(self) -> Optional[int]: - # return self._dm.brightness - return None + return self._dm.brightness # https://stackoverflow.com/questions/46885603/is-there-a-programmatic-way-to-check-if-brightness-is-at-max-or-min-value-on-osx # value = None # cmd = """nvram backlight-level | awk '{print $2}'""" @@ -335,11 +333,10 @@ def brightness(self) -> Optional[int]: # return value def setBrightness(self, brightness: Optional[int]): - # try: - # self._dm.setBrightness(brightness) - # except: - # pass - pass + try: + self._dm.setBrightness(brightness) + except: + pass # https://github.com/thevickypedia/pybrightness/blob/main/pybrightness/controller.py # https://eastmanreference.com/complete-list-of-applescript-key-codes # for _ in range(32):